Volume Optimization
Allocates non-negative integer volumes across a set of decision units so that, combined with current inventory projected into the same unit space, the resulting per-attribute supply matches per-attribute supply targets as closely as possible.
The optimization lives in ai_ppm.volume.optimizer (package packages/ai_ppm) and is intentionally generic — it makes no assumption about what one element of the decision vector represents. Each caller supplies:
- the catalog of decision units
- the per-unit baseline derived by projecting inventory into decision-unit space
- the per-attribute target supplies
- (when applicable) the externally-imposed allocation constraints
ai_ppm is currently a standalone library with no Dagster consumer wired into any OEM's DAG.
This page illustrates the concepts with a dealer allocation example: the decision unit is a model line, so x chooses how many additional vehicles to take per model line, and c is the dealer's current inventory counted by model.
Decision Unit
The vector x has length N, where N is the number of decision units. A decision unit is whatever the lowest level of granularity is at which volume is being chosen for a given application. The optimizer is unit-agnostic; the only requirements are that each unit:
- Can be assigned an integer non-negative count.
- Can be classified into one or more attribute groups.
- May participate in zero or more allocation groups.
Common choices in practice — listed roughly in order of decreasing granularity — include:
| Application | Decision unit | Approx N |
|---|---|---|
| Auction-candidate selection | Individual auction listing | 10⁵–10⁶ per model |
| Pre-purchase planning at full build resolution | Build code, or (build_code, QOP) pair | tens to low hundreds |
| Dealer allocation | Model line (model_id) | single-digit per dealer |
| Brand-level rollup | Brand | 1–5 |
The math is identical across all of these. What changes is:
- the meaning of one row of
x - the rows of the attribute matrix
- how inventory is projected into decision-unit space to produce the baseline
c - which allocation constraints, if any, apply
The same optimizer can be reused at whichever level the downstream consumer needs.
Inventory Projection
Inventory in the warehouse is per-vehicle (one row per VIN in consolidated/inventory). Before it can serve as the baseline c, it must be projected into decision-unit space — i.e. aggregated so each decision unit receives a single integer count.
For a model-line decision unit, the projection is typically a group_by(model_id).len() over currently-held (unsold) inventory: c[i] is the count of vehicles whose model_id matches decision unit i, with c[i] = 0 for units with no current inventory.
The projection rule depends entirely on the decision-unit choice — for a build-code-level optimization the same inventory would be projected via group_by(build_code).len(), and for a greenfield decision with no relevant prior inventory it collapses to c = 0.
Problem Statement
We choose how many additional units to acquire so that the realized per-attribute-group supply falls close to a desired target supply, weighted by how much we care about each attribute group.
Variables
| Symbol | Shape | Meaning |
|---|---|---|
x | (N,) integer, ≥ 0 | Decision variable — additional volume per decision unit (for dealer allocation: additional vehicles to take per model_id) |
c | (N,) integer | Baseline — current inventory projected into decision-unit space (for dealer allocation: count of currently-held vehicles per model_id; see Inventory Projection) |
target | (N,) integer | Derived: target = x + c — total per-unit volume after the decision |
Attribute grouping (soft objective)
Units roll up into one or more attribute groupings (e.g. two-door vs four-door, premium vs non-premium, color family, drivetrain). All such indicator rows are stacked into one matrix:
| Symbol | Shape | Meaning |
|---|---|---|
O | (K, N) sparse 0/1 | Row k is an indicator of units belonging to attribute group k |
t | (K,) | Desired total supply per attribute group |
w | (K,) | Per-group importance weight |
K is the total number of attribute-group rows summed across every attribute dimension. Disjoint dimensions (two-door / four-door is one dimension; premium / non-premium is another) coexist in the same O as separate rows. There is no need for a separate matrix per dimension.
This page uses the row-oriented (K × N) convention so O @ (x + c) is the natural sparse matrix-vector product. The equivalent column-oriented (N × K) form would multiply on the right as (x + c)ᵀ · O.
Allocation grouping (hard constraint)
When the application has externally-imposed quotas — for example, OEM-handed-down allocations that assign a number of vehicles to a set of units — these are modeled as hard constraints:
| Case | Description | Constraint |
|---|---|---|
| Single-unit allocation | The allocation is tied to exactly one unit | x_i is fully determined; nothing to optimize for that unit |
| Multi-unit allocation, free distribution | Allocation of a_m spread freely across units in group m | Σ_{i ∈ m} x_i = a_m |
| Multi-unit allocation, restricted distribution | Allocation of a_m with split / max-count rules | not yet supported (see Open Questions) |
Allocations are encoded as a sparse 0/1 matrix A of shape (M, N) plus an allocation-amount integer vector a of shape (M,). Two semantics are currently supported via allocation_mode:
"equality"(default) —A @ x = a, the free-distribution case"upper_bound"—A @ x ≤ a, useful when the allocation is a cap rather than a quota
Applications without hard quotas (e.g. unrestricted auction-candidate selection) leave the allocation inputs unset.
Split / max-count rules are not yet supported. They require auxiliary binary variables and are conditioned on confirmation that the rule actually exists for a given application.
Objective
Minimize the weighted L1 deviation of per-attribute-group supply from the desired target supply:
minimize ‖ w ⊙ ( O @ (x + c) − t ) ‖₁
subject to x ∈ ℤ₊ⁿ
A @ x = a (when allocation_mode="equality")
A @ x ≤ a (when allocation_mode="upper_bound")
The L1 norm is preferred over L2 because it tolerates the integer-rounding artifacts of a MIP without inflating outliers, and because the objective stays linear when allocation constraints are themselves linear.
Days-of-supply target
The per-group target supply t is the soft optimization goal — the inventory the MIP steers each group toward. The ai_ppm.volume.allocation wrapper derives it from target_days_supply × daily_sales_rate; target_days_supply defaults to DEFAULT_TARGET_DAYS_SUPPLY (110 days). Consumers of ai_ppm set the value themselves — for the ai_ordering service it lives on the OEM profile (OrderingOEMProfile.target_days_supply), and a run overrides it per-run via VolumeOptimizationConfig.target_days_supply.
Days-of-supply ceiling
The MIP accepts a K-dim group_ceiling (build_problem param) that upper-bounds post-decision inventory per group: A @ x + baseline ≤ group_ceiling. The ai_ppm.volume.allocation wrapper populates it from max_days_supply × daily_sales_rate when the caller supplies max_days_supply; omit or pass None to disable. Groups already at or above the ceiling clamp to zero additional allocation rather than making the problem infeasible. Consumers of ai_ppm set the value themselves — for the ai_ordering service, it lives on the OEM profile (OrderingOEMProfile.max_days_supply).
Allocation volumes as a suggestion vs. a cap
Allocation.volume is optional. A row with volume=None contributes only eligibility — its model_ids enter the decision space, but no M·x ≤ b row is added for it. A row with a concrete volume still enforces the cap per allocation_mode. Callers whose OEM allocation counts are guidance (Stellantis) emit None; callers with genuine per-allocation caps supply the count. Rows with volume=0 mean "OEM extended zero" and are dropped entirely.
Current Implementation
ai_ppm.volume.optimizer.build_problem(...) returns a VolumeProblem exposing the cvxpy problem, the integer variable x, and the target expression. VolumeProblem.solve(**solver_kwargs) runs the solver (cvxpy auto-selects an installed MIP backend) and returns x as np.int64. VolumeProblem.target_value exposes (x + baseline) as a rounded np.int64 array after solve.
The attribute-soft-penalty matrix O is passed as attribute_matrix; allocation constraints are passed as allocation_matrix + allocation_amounts with semantics chosen via allocation_mode.
Example
A minimal call against the generic optimizer. Three decision units (three model_ids); one attribute group (AWD); one allocation that may be split between two of the model lines.
import numpy as np
import scipy.sparse as sp
from ai_ppm.volume.optimizer import build_problem
# 3 decision units (model_ids); 1 attribute group (is_awd).
# model_ids: ["mid_a", "mid_b", "mid_c"] — AWD for a and c, FWD for b.
attribute_matrix = sp.csr_matrix(np.array([[1, 0, 1]], dtype=np.int64))
# Baseline c: current dealer inventory projected into model space —
# i.e. count of currently-held vehicles per model_id.
# (mid_a → 10 vehicles, mid_b → 5, mid_c → 2.)
baseline = np.array([10, 5, 2], dtype=np.int64)
# Want 15 AWD vehicles in supply after the decision; weight 1.0.
attribute_targets = np.array([15.0])
attribute_weights = np.array([1.0])
# Allocation of 7 vehicles split freely across mid_a and mid_b.
allocation_matrix = sp.csr_matrix(np.array([[1, 1, 0]], dtype=np.int64))
allocation_amounts = np.array([7], dtype=np.int64)
vp = build_problem(
attribute_matrix=attribute_matrix,
baseline=baseline,
attribute_targets=attribute_targets,
attribute_weights=attribute_weights,
allocation_matrix=allocation_matrix,
allocation_amounts=allocation_amounts,
)
x = vp.solve() # additional volume per model_id
supply = vp.target_value # = x + baseline (post-decision supply per model_id)
A consuming OEM project would wrap this with helpers that build attribute_matrix, baseline, and allocation_matrix from its own consolidated tables.
Future Extension: Time Dimension
The current formulation chooses volumes for a single planning horizon. For applications that care about when volume becomes available — e.g. planning across production dates so that supply arrives in the right month — the same formulation extends to an (N × T) decision tensor with no change in structure:
| Symbol | Single-horizon | With T time slots |
|---|---|---|
x, c, target | (N,) | (N, T) — per-unit, per-time-slot volume |
O | (K, N) | (K, N, T) — attribute-group membership of unit i in time slot t |
A | (M, N) | (M, N, T) — allocation m covers (unit, time) pairs |
t (targets) | (K,) | (K, T) per time slot, or (K,) if applied to time-aggregated supply |
w (weights) | (K,) | (K, T) per time slot, or (K,) |
In the typical case where attribute-group membership does not vary across time, the (K, N, T) tensor O collapses to a (K, N) matrix applied independently per slot. Allocations either pin a specific time slot (the usual case for OEM production allocations: "build a_m of these units in month t") or sum across a window.
Implementation is a straight flattening: treat the (N, T) decision as a length-N·T vector and widen O and A to the corresponding (K, N·T) and (M, N·T) block-diagonal forms. The optimizer itself needs no structural change — only the way the caller arranges its inputs. We do not currently expose this, but it is what we would solve once production-date sensitivity becomes a requirement.
Open Questions
- Decision-unit choice for OEM pre-purchase planning. Build code alone,
(build_code, QOP)pair, or model line? The optimizer itself does not depend on this — it only changes how upstream data is rolled up into thexindex. - Multi-unit allocation semantics. When an allocation covers multiple units, are we free to distribute the total however we want, or is there a hard rule (split evenly, take all from one, max-of-2-units, …) that requires auxiliary binary variables?
- Target supply derivation. What inputs determine the per-group target supply
t? - Solver selection. cvxpy auto-selects an installed MIP backend (HiGHS ships free; MOSEK requires a license, no perpetual size-limited tier).
- Candidate-selection scale. At
Nin the 10⁵–10⁶ range, can a single MIP solve replace the current iterative candidate-selection loop within an acceptable wall-clock budget?