Skip to content

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

  1. What allocation does
  2. The pipeline position of allocation
  3. How the score-based allocator worked before (recap)
  4. The gap the consequence term closes
  5. New consequence layer — opt-in, OFF by default
  6. How the consequence value is computed
  7. The three test scenarios
  8. Seeded data reference
  9. How to drive the test
  10. Expected results
  11. Verifying via the API + UI
  12. Cleanup
  13. 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

forecast → netting → indirect-demand → supply-planning → ALLOCATION → MEIO/K-curve

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:

  1. 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).
  2. Hard-constraint filter — drop candidates that violate a hard routing rule.
  3. 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 from master.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:

  1. load_where_used(conn) builds {child_item_id → [ParentLink(parent, site, ratio)]} from master.bill_of_material (active rows only).
  2. compute_consequence_weights(...) aggregates total demand and total available supply per (item, location), then for each parent FG that has demand:
  3. walks its immediate children; a child is binding when required − supply_available > 0;
  4. exactly one binding child → that component demand inherits the parent FG's priority score as its consequence weight (allocating it unblocks the FG);
  5. ≥2 binding children → the parent is already doomed; no child gets any consequence from this parent (zero marginal value);
  6. 0 binding children → parent is fully buildable; no marginal value either.
  7. The per-demand consequence_weight is injected into the Demand records; 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

cd files
python scripts/seed_consequence_alloc_test.py --yes
(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))"
(Adjust 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)

{'D_FGA': 5000.0}
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})
"
Expected: {'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} — the score_breakdown now includes consequence_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 includes consequence_component.
  • The allocation overview/heatmap in the UI (the score_breakdown tooltip) 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

python scripts/seed_consequence_alloc_test.py --restore
Removes all 9101xxx items, sites, BOM edges, on-hand rows and firm orders (tagged source consequence_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