TrueShelf: Basket-Value-Weighted Slot Allocation for US Micro-Fulfillment Centers
HitBase Research · Quick Commerce · Version 0.1.0 · August 2026
Abstract
US micro-fulfillment centers carry roughly 2,000–5,000 SKUs against a
catalogue of 40,000 or more; slots are the binding constraint, not capital
and not forecast quality. The industry default is to forecast per-SKU demand
as accurately as possible, rank by revenue or velocity, and cut at the slot
limit, and both steps optimise the wrong variable. TrueShelf plans
assortment and depth against basket value at risk (BVAR): the expected
basket margin that dies when a stockout meets a shopper with no acceptable
substitute. On a synthetic US dark store with known ground truth, the
BVAR-coupled policy is compared against the strongest conventional
alternative (an accuracy-optimal, independent per-SKU newsvendor sharing the
same forecasts) across 200 seeded replays of held-out demand.
Basket abandonment falls from 20.9% to 13.3% of
orders (-36.4%, 95% CI [-36.6%, -36.2%]), weekly margin lost in
abandoned orders falls -45.4% (CI [-45.7%, -45.0%]), and realized margin
per slot moves -0.6% (CI [-0.6%, -0.5%]). The gap concentrates where the
thesis says it should: in low-margin, low-substitutability anchor SKUs that
the profit-optimising baseline drops and this system retains. All published
numbers trace to committed files under results/; every artifact regenerates
from a clean checkout with one command.
1. The problem: slots, not forecasts
A US dark store (a Gopuff or DashMart-class micro-fulfillment center) is a planogram with roughly 1,400 slots facing a candidate catalogue several times that size. The decision that matters is not "how much of X will sell tomorrow" but "which 1,300 of these 2,600 SKUs deserve a facing, and how deep behind it." The industry default answers with a proxy chain: forecast per-SKU demand, rank by trailing revenue or velocity, cut at the slot limit, spread depth uniformly. Three claims, each made measurable in this system, say the proxy chain fails in a predictable direction:
- Stockout cost is a basket property, not a SKU property. When a shopper cannot find an item and has no acceptable in-stock substitute, a meaningful share abandon the entire order rather than checking out partially. The correct unit of loss is expected basket value at risk, not the missing item's margin.
- Substitutability is the modifier that decides everything. Two SKUs with identical demand and margin have wildly different shelf-worth if one has three close substitutes on the same shelf and the other has none. Assortment decisions made without a substitution model over-stock crowded categories and under-stock anchors.
- Point-forecast accuracy is not the objective. The decision is a constrained newsvendor problem under substitution; the loss is asymmetric and coupled across SKUs, so a model that wins on MAPE can lose money against a worse-MAPE model with calibrated upper quantiles.
The canonical example is a gallon of whole milk, a dozen large eggs, bananas: low margin, near-zero substitutability across brand for many households, and the reason the trip happens at all. Revenue ranking cannot see them, though, as §8 shows, the sharper finding is that a profit-optimising baseline actively drops them.
2. Why this is hard
Demand is censored by the shelf itself. When a SKU is out of stock, observed sales are a lower bound on demand, and stock runs out exactly when demand runs high, so the surviving observations skew low in precisely the hours a depth decision cares about (§4).
Co-purchase confounds substitution. Peanut butter and jelly co-occur constantly and substitute not at all. Substitution must be estimated from signals that separate "bought together" from "accepted instead" (§5).
Basket value must be attributed, not observed. The margin that dies with an abandoned order has to be attributed to the item whose absence killed it; attributing the full basket to every member double-counts wildly (§6).
Value is not separable across SKUs. A SKU's abandonment propensity depends on which of its substitutes are also stocked, so assortment value is coupled and the allocator must own that coupling rather than pretend independence (§7).
3. System architecture
The Python package is qcshelf (the import namespace is deliberately
decoupled from the display name; a CI test keeps the product name confined to
one branding module). The planning core is a pure function:
def plan_store(state: StoreState, config: PlanConfig) -> StorePlan: ...
It has no I/O, no global state, no clock reads, and no hidden mutation, so a future
network layer can loop it over stores without refactoring
(qcshelf/allocate/planner.py). Around it:
| layer | modules | role |
|---|---|---|
| data | data/synth.py, data/behavior.py, data/sources.py | seeded US dark-store world; shared shopper physics; gated real-data loaders |
| calendar | calendar/us.py | US holidays (computed), biweekly paydays, state-staggered SNAP issuance |
| demand | demand/{features,baselines,quantile,calibration}.py | censoring-aware features, LightGBM quantile ensembles, conformal calibration |
| substitution | substitution/{attributes,switching,graph}.py | three signals blended into a sparse acceptance matrix with confidence |
| value | value/{bvar,anchor}.py | basket value at risk and anchor scoring |
| allocation | allocate/{knapsack,exact,planner}.py | coupled greedy with price search and swaps; exact ILP reference |
| simulation | simulate/{engine,policies,report}.py | paired seeded replay of five policies with bootstrap CIs |
| surface | api/, frontend/, llm/ | FastAPI + React console; grounded Vertex narration (optional, ADC-only) |
One deliberate joint: the shopper physics (stock allocation, substitution
acceptance draws, basket abandonment, a unit-exact ledger) lives once in
data/behavior.py and is called both by the history generator and the policy
simulator. The physics that produced the training data cannot drift from the
physics that scores policies.
4. Demand: quantiles, not points
Granularity is (SKU, hour) with the spec feature set: lags at 1/2/3/24/168
hours, rolling means and deviations over 24 hours and 7 days, hour-of-day and
day-of-week, US holiday flags, the biweekly payday window, the store-state
SNAP issuance intensity, promo flag, weather, relative price within category,
days since last sale, and the availability mask. Models are pooled LightGBM
quantile regressions at q ∈ {0.5, 0.8, 0.9, 0.95} because 2,600 mostly-intermittent
series cannot each support their own model, and pooling is what lets a
never-stocked SKU inherit a distribution from its category and price tier at
plan time. Baselines: seasonal naive, Croston, TSB
(results/demand/baseline_comparison.csv).
Censoring is explicit. The masked estimator excludes out-of-stock hours
from the loss. Two findings worth stating plainly (both measured against
ground truth in results/demand/censoring_ablation.csv): at the hourly
level masking beats the naive fit that learns stockout zeros as demand; at
the daily level masking alone is not enough; a day censors because
demand spiked, so dropping censored days deletes exactly the peaks, and the
masked daily fit can lose to naive against truth. The planning model
therefore uses a config-gated imputation arm (observed uncensored-hours sales
scaled by the store's hour-of-day curve), with masked, naive and imputed arms
all trained and compared.
Calibration is load-bearing. Miscalibrated upper quantiles silently
destroy the newsvendor step, so each ensemble carries split-conformal offsets
fitted on a held-out week, and coverage is reported per demand decile; count
targets are mostly zeros, and an overall coverage number is flattered by the
zero atom. On the uncensored test window the planning bundle's q0.9 coverage
is 0.980 overall and 0.954 in the busiest decile
(results/demand/coverage_calibration.csv); the conformal step is
finite-sample conservative, so coverage sits above target rather than below.
5. Substitution: three signals of unequal strength
Signal 1, attribute similarity (same category and subcategory, price proximity, national-brand vs private-label tier), is cheap, always available, and weak. It proposes candidate edges and gives each row a shape, never a level. Signal 2, conditional negative co-occurrence, asks: given a category purchase and both SKUs stocked that day, how much more rarely than independence were both bought? Conditioning within category controls the complementarity confound, but the signal still cannot separate substitutes from one-per-trip category structure, so it too only shapes rows. Signal 3, observed post-stockout switching (what did i-buyers take when i was out) is the strongest and sparsest; its estimator conditions the denominator on the substitute's own availability at the event hour, and category-level acceptance rates are availability-weighted (stockouts cluster: when one cola spikes out its siblings usually did too, and a raw event rate conflates willingness with availability).
The blend sets row levels from availability-adjusted category acceptance,
row shapes from signals 1–2, and overrides pairs with signal 3 weighted by
per-pair event confidence; row sums stay strictly below 1 with the remainder
read as P(abandon). Against the synthetic answer key
(results/substitution/recovery_metrics.json): edge precision
0.54, recall 0.76, F1 0.63 over all rows, improving to
0.65/0.77/0.70 on rows with behavioural exposure;
never-stocked SKUs carry only the attribute prior, and their rows dominate
the overall error. Two honesty notes: the attribute prior shares structural
assumptions with the generator, which flatters recovery relative to real
data; and on public datasets with no stockout events, signal 3 vanishes
entirely and recovery would rest on the two weak signals alone.
6. Basket value at risk
For SKU i on horizon day d with planned depth :
P_stockout(i,d) P(daily demand > q_i), from the quantile forecast
U(i,d) E[unmet units | a stockout occurs]
avail_sub(i,d) Σ_j S[i,j] · fill(j,d)
λ(i,d) 1 − avail_sub(i,d)
A(i) attached basket margin at risk
BVAR(i,d) = P_stockout(i,d) · U(i,d) · [ margin_i + λ(i,d) · A(i) ]
U is defined conditional on the stockout, so P·U equals the plain expected shortfall E[(D − q)⁺] and nothing is double-counted. Demand enters as a piecewise-linear quantile function through the published levels with a tail anchor at q95 + 2·(q95 − q90); every BVAR number is exact arithmetic on those segments, golden-tested against hand-computed fixtures.
A(i) is an attribution estimate, not an observation. The scheme attributes through the inferred trip-intent role: A(i) = P(anchor | i in basket)·β̂_anchor·E[rest margin | i anchor] + P(attached)·β̂_attached·E[rest margin | i attached], with the β's (P(order abandoned | that role unmet, no substitute)) estimated from the order log. The attached-role β must be de-confounded: an attached short line in an order whose anchor also stocked out inherits the anchor-triggered abandonment and would inflate β̂_attached several-fold, so it is estimated only from orders with a fully served anchor and exactly one short attached line. Direction of bias, stated rather than hidden: rest-of-basket margins are computed over obtained lines, so baskets truncated by other stockouts push A down; the per-line attached β mildly overstates when several attached lines go short at once; role inference is taken as given. The anchor score (λ under the isolated row mass times A(i)) is exposed as a first-class output and drives the anchor leaderboard: small margin, high λ, high A is an anchor, cheap and unsubstitutable and the reason the order exists.
7. Allocation: owning the coupling
Slots are facings and bind inclusion; cubic feet bind depth (docs/DECISIONS.md D-012). Because λ depends on what else is stocked, value is coupled and roughly submodular: the second cola in a crowded aisle is worth less than the first, so greedy on marginal value per slot is principled rather than arbitrary. Spoilage cost breaks strict monotonicity, the 1−1/e guarantee does not apply, and the greedy's quality is therefore measured, not asserted. The implementation alternates μ-priced greedy inclusion (candidates enter at the depth a cubic-feet price μ implies) with Lagrangian depth repricing, searches μ over a small grid with every candidate plan scored by the exact evaluator, and finishes with a swap pass. Without the price search, a handful of deep early picks starve the cubic budget while slots sit stranded, the failure mode the exact reference exposed during development.
The exact ILP (CP-SAT) is exact for the decoupled objective: per-unit depth
binaries with decreasing served-unit values, substitution coupling frozen at
all-stocked fill rates (D-013). Both solutions are re-scored under the full
nonlinear objective (results/allocation/optimality_gap.csv): across
instance sizes 50–300 the heuristic's gap runs -45.0% to -0.1%; negative
throughout, i.e. the coupled heuristic beats the decoupled-exact reference,
increasingly so at larger sizes where frozen coupling misprices more. This is
a statement about the reference's linearisation as much as the heuristic's
quality, and is reported as such.
8. Results
Protocol. Five policies plan the same store from the same observables:
top_revenue, top_units, category_quota (the common human heuristic),
accuracy_optimal (independent per-SKU newsvendor on the same quantile
forecasts, no substitution or basket model), and bvar_coupled (this
system). accuracy_optimal and bvar_coupled share forecasts and
optimiser; the only difference is a world with zero substitution and zero
attached value, so whatever separates them is the decision model, not forecast
quality. Each policy's fixed par plan is replayed against 200 seeded
weeks of held-out demand with identical shopper sessions per seed (paired
comparison); uncertainty is a seed-level bootstrap. Per-seed rows:
results/simulation/policy_comparison.csv; summary with 95% CIs:
results/simulation/policy_summary.json.
Headline comparison (treatment vs accuracy_optimal, the strongest
conventional baseline; beating top_units is not a finding):
| metric | accuracy_optimal | bvar_coupled | relative | 95% CI |
|---|---|---|---|---|
| basket abandonment rate | 20.9% | 13.3% | -36.4% | [-36.6%, -36.2%] |
| weekly margin lost in abandoned orders | $4,486.08 | $2,451.16 | -45.4% | [-45.7%, -45.0%] |
| realized margin per slot | $18.50 | $18.40 | -0.6% | [-0.6%, -0.5%] |
| fill rate | 67.4% | 78.6% | +16.6% | [+16.5%, +16.7%] |
| net margin | $19,463.54 | $20,700.58 | +6.4% | [+6.2%, +6.5%] |
The velocity rankers trail accuracy_optimal throughout, as expected, and
accuracy_optimal loses to the coupled policy, with the gap concentrated
where the thesis predicts. results/simulation/anchor_attribution.csv
buckets the per-SKU margin gap by anchor-score decile: the top three anchor-score deciles carry $890.64 of the $1,800.24 total weekly per-SKU margin gap (49%).
Anchor retention (a plan property with no seed-level variation, so it
carries no CI and is deliberately not headlined; D-016): of the 100
strongest true anchors, bvar_coupled stocks 100%,
accuracy_optimal 90%, top_revenue 100%. The honest
surprise: revenue ranking keeps anchors here. Milk sells a lot, so its
trailing revenue is high despite a dime of margin, while the
profit-optimising newsvendor is exactly the policy that drops them, because
low margin times served units is a poor profit contribution when the basket
is invisible. "Rank by revenue misses anchors" is the folklore; "optimise
per-SKU profit and you will actively delist them" is what the measurement
says.
Sensitivity (results/simulation/sensitivity_sweep.csv; one-at-a-time
around the base world, 40 seeds per variant): the coupled policy's net-margin lead over accuracy_optimal survives 10 of 10 variants; it is largest under spoilage_scale=2.0 ($2,394.48/week) and smallest under beta_anchor=0.55 ($614.14/week).
Headline metrics (results/headline.json, computed from the artifacts
above, never typed by hand). Two selection rules are enforced in code: a
candidate whose 95% CI crosses zero is excluded, and a candidate whose
measured direction contradicts its claimed outcome is excluded rather than
relabelled. That is why the margin-per-slot "uplift" candidate does not
appear below (-0.6% is a small honest loss, not a win), and why the
published set is smaller than three:
- +45%: Less basket value lost to walkaways. Weekly margin lost in abandoned orders: $4,486 -> $2,451. (95% CI of the relative change: [-45.7%, -45.0%]; source:
results/simulation/policy_summary.json→treatment_vs_baseline.abandon_loss_usd) - +36%: Fewer abandoned baskets. Basket abandonment rate: 20.9% -> 13.3% of orders. (95% CI of the relative change: [-36.6%, -36.2%]; source:
results/simulation/policy_summary.json→treatment_vs_baseline.abandonment_rate)
9. Limitations
Synthetic ground truth is the only counterfactual source. The policy comparisons require knowing demand behind stockouts and the true substitution matrix; only the generator provides either. Public US datasets (M5, dunnhumby, Instacart) carry no store-level stockout events, slot constraints or availability masks. They can validate the demand, calendar and affinity machinery, and nothing here claims otherwise (docs/DATA.md).
The estimator sees a world its priors resemble. Attribute-based substitution candidates share structure with the generator; recovery numbers are best read as an upper bound on real-data performance, and rows for never-stocked SKUs are prior-only.
Single site, v0 scope. One store at a time; no inter-store transfers, no
courier or ETA modelling, no dynamic pricing, no live WMS sync, no vendor
generation. The pure plan_store boundary exists so the network layer can
arrive without refactoring the core.
Behavioural simplifications. Hours are naive store-local (DST days are 24 hours; D-001); within-hour demand is allocated in arrival order with one substitution attempt per short line (D-007); SNAP schedules are published day-windows rather than per-case-digit exactness (D-002). Each is documented where it lives.
Baselines are v0. accuracy_optimal is a strong conventional policy but
not the frontier of assortment science; the comparison bounds the value of
basket-coupling against the best forecast-first approach, not against every
possible competitor.
10. Roadmap
Network-level allocation with transfers (the outer loop over plan_store),
price–substitution interaction (acceptance probabilities that respond to the
price gap), live inventory integration behind the same StoreState
boundary, and richer intent inference to sharpen A(i) attribution.
Every figure in this document cites a file under results/;
results/env.json records the seed set, git SHA, package versions and wall
clock of the run that produced them. Regenerate everything with
make results.
