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_core.volume.optimizer 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, and (when applicable) the externally-imposed allocation constraints.
This page uses Stellantis as the running example throughout. For Stellantis, the decision unit is the model (one entry per model_id from consolidated/models), 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 |
| Stellantis dealer allocation (current) | 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, and 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 Stellantis, the projection is a group_by(model_id).len() on the dealer's slice of inventory_enriched filtered to is_in_inventory = True (vehicles not yet sold): c[i] is the number of currently-held vehicles whose model_id matches decision unit i. Decision units with no current inventory get c[i] = 0. The is_in_inventory flag is computed by StellantisInventoryEnrichmentComponent as inferred_sold_date IS NULL; consolidated/inventory is joined with inventory_enriched on inventory_id inside the asset to attach it.
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.
In the Stellantis pipeline this projection lives in build_baseline_per_model in ai_stellantis.volume.optimize, called from the stellantis/<market>/dealers/optimal_allocations asset.
Allocation Table Enrichment
OEM allocation rows arrive from BigQuery keyed on model_codes — an array of opaque codes the OEM uses internally. To make the table usable by downstream consumers without re-joining consolidated/models on every read, the stellantis/<market>/dealers/allocations asset adds a sibling model_ids column whose type is array<string> (Polars List[Utf8], Iceberg list<string>).
Each row's model_ids is the deduplicated union of model_id values whose model_code appears in that row's model_codes. Codes absent from consolidated/models contribute nothing; rows with an empty model_codes list keep an empty model_ids list. The column is materialized as a true array so consumers can iterate or UNNEST it natively — never as a comma-joined string.
The enrichment lives in enrich_with_model_ids in ai_stellantis.volume.allocations and runs in the same asset op that pulls the BQ snapshot.
Problem Statement
We choose how many additional units to acquire so that the realized per-attribute-group supply lands 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 Stellantis: additional vehicles to take per model_id) |
c | (N,) integer | Baseline — current inventory projected into decision-unit space (for Stellantis: 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 gated 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.
Current Implementation
ai_core.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 Stellantis-flavored call against the generic optimizer. Three decision units (three model_ids); one attribute group (AWD); one allocation that may be split between the two model lines that map to its model_code.
import numpy as np
import scipy.sparse as sp
from ai_core.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])
# OEM allocation of 7 vehicles for model_code MPJM74, which the
# consolidated/models catalog resolves to 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)
The Stellantis component wraps this with helpers that build attribute_matrix, baseline, and allocation_matrix from the consolidated tables — see ai_stellantis.volume.optimize.
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 lands 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? Liam indicated(build_code, QOP); awaiting confirmation from Benedict. 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? Discussion with Katherine pending. - 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?