Skip to main content

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:

  1. Can be assigned an integer non-negative count.
  2. Can be classified into one or more attribute groups.
  3. May participate in zero or more allocation groups.

Common choices in practice — listed roughly in order of decreasing granularity — include:

ApplicationDecision unitApprox N
Auction-candidate selectionIndividual auction listing10⁵–10⁶ per model
Pre-purchase planning at full build resolutionBuild code, or (build_code, QOP) pairtens to low hundreds
Dealer allocationModel line (model_id)single-digit per dealer
Brand-level rollupBrand1–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

SymbolShapeMeaning
x(N,) integer, ≥ 0Decision variable — additional volume per decision unit (for dealer allocation: additional vehicles to take per model_id)
c(N,) integerBaseline — current inventory projected into decision-unit space (for dealer allocation: count of currently-held vehicles per model_id; see Inventory Projection)
target(N,) integerDerived: 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:

SymbolShapeMeaning
O(K, N) sparse 0/1Row 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:

CaseDescriptionConstraint
Single-unit allocationThe allocation is tied to exactly one unitx_i is fully determined; nothing to optimize for that unit
Multi-unit allocation, free distributionAllocation of a_m spread freely across units in group mΣ_{i ∈ m} x_i = a_m
Multi-unit allocation, restricted distributionAllocation of a_m with split / max-count rulesnot 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:

SymbolSingle-horizonWith 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 the x index.
  • 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 N in the 10⁵–10⁶ range, can a single MIP solve replace the current iterative candidate-selection loop within an acceptable wall-clock budget?