Allocation Engine — Functional Walkthrough & Consequence Test Guide¶
Date: 2026-06-25
Tenant: thebicycle (or any demo tenant)
Scope: End-to-end allocation behaviour — the existing score-based greedy allocator plus the new opt-in BOM-aware consequence scoring.
Seed script: files/scripts/seed_consequence_alloc_test.py
TOC¶
- What allocation does
- The pipeline position of allocation
- How the score-based allocator worked before (recap)
- The gap the consequence term closes
- New consequence layer — opt-in, OFF by default
- How the consequence value is computed
- The three test scenarios
- Seeded data reference
- How to drive the test
- Expected results
- Verifying via the API + UI
- Cleanup
- Key files
What allocation does¶
Allocation answers the planner question: "I have a finite supply pool (on-hand inventory + planned/firm supply orders) and many competing demands (netted forecast + firm customer orders) — which demand gets which unit, in what order?"
It runs after forecasting, netting and supply planning, and writes three ClickHouse result tables that power the allocation overview / detail / explain views in the UI:
PIPE_allocation_results— one row per (demand, supply) allocation with the full score breakdown.PIPE_allocation_unfulfilled— demands that could not be fully filled, with the reason.PIPE_allocation_explanations— the narrative lines behind every decision.
The pipeline position of allocation¶
Allocation's inputs are produced by the upstream steps:
| Input | Source | What it carries |
|---|---|---|
| Demand | PIPE_forecast_netting.supply_plan_qty (= unconsumed forecast + firm orders) |
per (item, site, week) qty |
| Demand metadata | master.item / master.demand_actuals / master.customer |
priority_class, strategic/revenue/criticality weights |
| Supply | master.on_hand (good+new) + PIPE_supply_orders (planned) + MASTER_supply_orders_firm (firm) |
on-hand + planned/firm receipts |
| Parameters | allocation_parameter_set (7 types, segment-resolved) |
priority scores, starvation, reservation, fairness, routing, aging, lateness |
The runner (allocation_runner.py:run_allocation_pipeline) loads all of this,
hands it to the Rust engine as three JSON blobs, and persists the engine's
JSON result back to ClickHouse.
How the score-based allocator worked before (recap)¶
The engine (files/supply/src/score_alloc) is a greedy, single-level,
deterministic allocator:
- Candidate generation — for every demand, pair it with every same-location supply unit that arrives within the demand's date tolerance. Location-gated (a demand at site A never sees supply at site B).
- Hard-constraint filter — drop candidates that violate a hard routing rule.
- Scoring (parallel via Rayon) — each surviving candidate gets a composite score:
score = priority + aging + lateness + strategic + revenue + criticality
− routing_penalty − fairness_penalty − reservation_penalty
priority— the demand's PriorityClass (AOG > Critical > Premium > Standard > Low) × a configurable score table.aging— urgency from how long the demand has been waiting (starvation escalation).lateness— penalty for expected delivery delay.strategic/revenue/criticality— static per-item business weights frommaster.item.-
routing/fairness/reservation— policy penalties. -
Greedy allocation — sort candidates descending by score (deterministic tiebreak on demand_id, supply_id) and greedily consume supply. Fairness hard-caps and reservation fences are the only multi-demand coupling.
The key limitation: every (demand, supply) pair is scored independently. The engine never looks at the bill_of_material, so it cannot tell that shorting component C₁ only matters when it is the sole missing part for a buildable FG, and is worthless when another component C₂ is already short.
The gap the consequence term closes¶
Planner intuition (the behaviour you asked for):
Not obtaining a component has a different implication depending on whether it blocks the build of a finished good. If it is the sole missing part, allocating it has high value — it unblocks a sale. If other components are missing anyway, allocating this one has zero consequence — the build stays blocked.
The static criticality_weight on master.item cannot express this: it is a
fixed per-item attribute, not a marginal value derived from downstream
buildability. So before this change, two equal-priority component demands
competing for the same supply were decided purely by the demand_id tiebreak —
even when one unblocks a CRITICAL FG build and the other feeds a doomed one.
New consequence layer — opt-in, OFF by default¶
A new consequence_component term is added to the score:
score = ... + criticality + consequence_component − ...
consequence_component = demand.consequence_weight × consequence_weight_factor
It is a no-op unless a caller opts in via two environment variables, so existing allocation runs remain bit-identical to the pre-consequence path:
| Env var | Default | Effect |
|---|---|---|
ALLOCATION_CONSEQUENCE_FACTOR |
0.0 |
Global multiplier. Set > 0 (e.g. 1.0) to enable the consequence term. |
ALLOCATION_INCLUDE_INDIRECT_DEMAND |
0 |
When truthy, kit/make indirect component demand from PIPE_indirect_demand is unioned into the demand set so components needed by FG builds actually compete for the same supply pool (tagged with the parent FG's priority). |
Because the term defaults to zero and the indirect-demand union is gated off, every allocation run with the defaults is unchanged.
How the consequence value is computed¶
files/utils/consequence.py runs a 1-level (immediate-parent) where-used
pre-pass before the Rust engine runs:
load_where_used(conn)builds{child_item_id → [ParentLink(parent, site, ratio)]}frommaster.bill_of_material(active rows only).compute_consequence_weights(...)aggregates total demand and total available supply per(item, location), then for each parent FG that has demand:- walks its immediate children; a child is binding when
required − supply_available > 0; - exactly one binding child → that component demand inherits the parent FG's priority score as its consequence weight (allocating it unblocks the FG);
- ≥2 binding children → the parent is already doomed; no child gets any consequence from this parent (zero marginal value);
- 0 binding children → parent is fully buildable; no marginal value either.
- The per-demand
consequence_weightis injected into theDemandrecords; the Rust engine multiplies it by the factor and adds it to the score.
A component feeding multiple parent FGs takes the max consequence (the most valuable unblockable parent wins).
The three test scenarios¶
The seed script creates three isolated situations on reserved 9101xxx ids so they never collide with real data:
| # | Situation | FG | Component supply | Expected consequence |
|---|---|---|---|---|
| A | Sole binding child | FG_A (CRITICAL) | C1_A=100 ✓, C2_A=0 ✗ | C2_A demand earns 5000 (CRITICAL parent score); C1_A earns 0 |
| B | Parent already doomed | FG_B (STANDARD) | C1_B=0 ✗, C2_B=0 ✗ | BOTH C1_B and C2_B earn 0 (≥2 binding children → doomed) |
| C | Baseline (no BOM) | — (leaf) | leaf on-hand=0 | leaf earns 0 (no parent in the where-used graph) |
Each FG has 100 u of direct demand at week 0; each component is consumed at the FG build site with a 1:1 BOM ratio.
Seeded data reference¶
Items (master.item, reserved 91011xx):
- 9101101 ALLOCFG_A — finished good A (sole-binding demo)
- 9101102 ALLOCC1_A — component 1 of FG A (enough supply)
- 9101103 ALLOCC2_A — component 2 of FG A (short, the binding one)
- 9101104 ALLOCFG_B — finished good B (doomed demo)
- 9101105 ALLOCC1_B — component 1 of FG B (short)
- 9101106 ALLOCC2_B — component 2 of FG B (short)
- 9101107 ALLOCLEAF — baseline leaf (no BOM parent)
Sites (master.location, reserved 91012xx):
- 9101201 ALLOCSITE_A, 9101202 ALLOCSITE_B, 9101203 ALLOCSITE_L
BOM edges (master.bill_of_material, 1:1 ratio, active): - FG_A → C1_A, FG_A → C2_A (at site A) - FG_B → C1_B, FG_B → C2_B (at site B)
On-hand (master.on_hand): - C1_A = 100, C2_A = 0, C1_B = 0, C2_B = 0, FG/leaf = 0
Firm orders (master.demand_actuals, source consequence_alloc_test):
- FG_A @ site A: 100 u, critical
- FG_B @ site B: 100 u, standard
- LEAF @ site L: 100 u, standard
How to drive the test¶
1. Seed the data¶
(Use--restore to tear it all down afterwards.)
2. Verify the consequence pre-pass directly (no pipeline run needed)¶
python -c "
import sys; sys.path.insert(0,'files')
from db.db import get_conn, get_schema
from allocation_runner import load_demands, load_supplies, _load_item_meta
from utils.consequence import load_where_used, compute_consequence_weights
conn=get_conn(); schema=get_schema()
# load_demands reads PIPE_forecast_netting; for an isolated unit test of the
# pre-pass we build the demand/supply dicts directly from the seed instead:
import datetime
from allocation_runner import _to_epoch_days, _priority_to_engine, _today_epoch
from utils.planning_date import planning_today
today = planning_today()
demands = [
{'demand_id':'D_FGA','item_id':'9101101','location_id':'9101201','qty':100.0,
'required_date':_today_epoch(),'priority_class':'CRITICAL','consequence_weight':0.0},
{'demand_id':'D_FGB','item_id':'9101104','location_id':'9101202','qty':100.0,
'required_date':_today_epoch(),'priority_class':'STANDARD','consequence_weight':0.0},
{'demand_id':'D_LEAF','item_id':'9101107','location_id':'9101203','qty':100.0,
'required_date':_today_epoch(),'priority_class':'STANDARD','consequence_weight':0.0},
]
supplies = [
{'supply_id':'S_C1A','item_id':'9101102','location_id':'9101201','qty_available':100.0},
{'supply_id':'S_C2A','item_id':'9101103','location_id':'9101201','qty_available':0.0},
{'supply_id':'S_C1B','item_id':'9101105','location_id':'9101202','qty_available':0.0},
{'supply_id':'S_C2B','item_id':'9101106','location_id':'9101202','qty_available':0.0},
]
wu = load_where_used(conn)
w = compute_consequence_weights(demands, supplies, wu)
print({k:v for k,v in w.items() if v>0})
"
3. Run a full allocation with consequence ON vs OFF¶
# OFF (default — bit-identical to before):
python -c "import sys; sys.path.insert(0,'files'); from allocation_runner import run_allocation_pipeline as r; print(r(pipeline_id=4, scenario_id=1))"
# ON:
ALLOCATION_CONSEQUENCE_FACTOR=1.0 python -c "import sys; sys.path.insert(0,'files'); from allocation_runner import run_allocation_pipeline as r; print(r(pipeline_id=4, scenario_id=1))"
pipeline_id/scenario_id to your tenant. The seeded FG/component items
must appear in PIPE_forecast_netting for load_demands to pick them up; if
they are not yet netted, the direct pre-pass in step 2 is the authoritative
check.)
Expected results¶
Pre-pass weights (step 2)¶
Wait — that is the parent FG. The consequence weight is assigned to the component demand that sole-binds the parent. In the direct synthetic check above the demands list only contains the FGs themselves (which have no parent in the where-used graph), so the output is empty{}. The meaningful check is:
the C2_A component demand earns 5000.
Use the dedicated unit check instead (it includes the component demands):
python -c "
import sys; sys.path.insert(0,'files')
from db.db import get_conn
from utils.consequence import load_where_used, compute_consequence_weights
conn=get_conn()
demands = [
{'demand_id':'D_FGA','item_id':'9101101','location_id':'9101201','qty':100.0,'priority_class':'CRITICAL'},
{'demand_id':'D_C1A','item_id':'9101102','location_id':'9101201','qty':100.0,'priority_class':'STANDARD'},
{'demand_id':'D_C2A','item_id':'9101103','location_id':'9101201','qty':100.0,'priority_class':'STANDARD'},
{'demand_id':'D_FGB','item_id':'9101104','location_id':'9101202','qty':100.0,'priority_class':'STANDARD'},
{'demand_id':'D_C1B','item_id':'9101105','location_id':'9101202','qty':100.0,'priority_class':'STANDARD'},
{'demand_id':'D_C2B','item_id':'9101106','location_id':'9101202','qty':100.0,'priority_class':'STANDARD'},
{'demand_id':'D_LEAF','item_id':'9101107','location_id':'9101203','qty':100.0,'priority_class':'STANDARD'},
]
supplies = [
{'supply_id':'S_C1A','item_id':'9101102','location_id':'9101201','qty_available':100.0},
{'supply_id':'S_C2A','item_id':'9101103','location_id':'9101201','qty_available':0.0},
{'supply_id':'S_C1B','item_id':'9101105','location_id':'9101202','qty_available':0.0},
{'supply_id':'S_C2B','item_id':'9101106','location_id':'9101202','qty_available':0.0},
]
wu = load_where_used(conn)
w = compute_consequence_weights(demands, supplies, wu)
print('non-zero weights:', {k:v for k,v in w.items() if v>0})
"
{'D_C2A': 5000.0} — only the sole-binding component of the
CRITICAL FG A earns a consequence weight. C1_B and C2_B (doomed parent) get 0;
C1_A (enough supply, not binding) gets 0; the leaf gets 0.
Full allocation (step 3)¶
With ALLOCATION_CONSEQUENCE_FACTOR=1.0, any C2_A component demand that
competes for shared supply will outrank an equal-priority demand that carries
consequence_weight=0 — the greedy order flips to favour the unblockable FG
build. With the factor at 0.0 (default) the order is unchanged.
Verifying via the API + UI¶
After a real allocation run, the score_consequence column is populated and
surfaced in:
/api/allocation/explain/{demand_id}— thescore_breakdownnow includesconsequence_component, and the narrative contains a "Consequence: unit has high marginal value (...)" line when the term > 0./api/allocation/supply/{supply_id}(detail competition) — the per-demand breakdown includesconsequence_component.- The allocation overview/heatmap in the UI (the
score_breakdowntooltip) shows the new component alongside the existing ones.
The schema migration (ch_migration_allocation_consequence.sql) adds the
score_consequence Float64 DEFAULT 0 column idempotently and is wired into
apply_history_schema() (step 7), so it runs automatically on the next tenant
bootstrap / API startup.
Cleanup¶
Removes all 9101xxx items, sites, BOM edges, on-hand rows and firm orders (tagged sourceconsequence_alloc_test).
Key files¶
| Layer | File | Role |
|---|---|---|
| Rust models | files/supply/src/score_alloc/models.rs |
consequence_weight on Demand, consequence_component in ScoreBreakdown/AllocationExplanation |
| Rust config | files/supply/src/score_alloc/config.rs |
consequence_weight_factor (default 0), include_indirect_demand |
| Rust scoring | files/supply/src/score_alloc/scoring.rs |
the consequence_component term |
| Rust engine | files/supply/src/score_alloc/engine.rs |
narrative + 3 consequence unit tests |
| Python pre-pass | files/utils/consequence.py |
load_where_used, compute_consequence_weights, ParentLink |
| Runner | files/allocation_runner.py |
env toggles, load_indirect_demands, _load_item_meta, pre-pass wiring, score_consequence write |
| DDL | files/DDL/allocation_ch_schema.sql, files/DDL/ch_migration_allocation_consequence.sql |
score_consequence column |
| Migration wiring | files/historization/migrations.py |
apply_allocation_consequence_migration |
| API | files/api/main.py |
_alloc_score_breakdown_from_row, explain + detail endpoints surface consequence_component |
| Seed | files/scripts/seed_consequence_alloc_test.py |
isolated 9101xxx test scenario |