Allocation Algorithm¶
The allocation engine is a deterministic, score-based Rust algorithm that distributes a finite supply pool across competing demands. It runs as a native extension (PyO3 + Rayon) and completes in under 500 ms on production-scale datasets (20 000+ demands, 1 000+ supply buckets).
Purpose¶
Given:
- A set of demands (open customer orders, production requirements, forecast buckets) each with a required date, quantity, priority class, and optional business weights
- A set of supply buckets (on-hand inventory, purchase orders, manufacturing orders, transfers) each with an available date, quantity, and location
The algorithm assigns scarce supply to demands to minimise total business pain — prioritising urgency, fairness, and future demand protection.
Figure: the allocation engine takes constrained supply plus competing demands and produces reservations, a backlog remainder, and per-demand explanations.
flowchart LR
D["Demands<br/>(orders + forecast)"] --> E["Allocation engine<br/>(8-pass, Rust)"]
S["Constrained supply<br/>(on-hand + PIPE_supply_orders)"] --> E
E --> R["Reservations<br/>(PIPE_allocation_results)"]
E --> U["Unfulfilled backlog<br/>(PIPE_allocation_unfulfilled)"]
E --> X["Explanations<br/>(PIPE_allocation_explanations)"]
Design goals:
| Goal | How it is achieved |
|---|---|
| Explainable | Every allocation includes a decomposed score + human-readable narrative |
| Configurable | All policy parameters live in the database; zero code changes for tuning |
| Deterministic | Sort-based tie-breaking; identical inputs always produce identical outputs |
| Fast | Rayon parallel scoring; 10 000 demands × 2 000 supply buckets < 2 s on 4 cores |
The 8-Pass Algorithm¶
The engine runs eight sequential, deterministic passes.
Pass 1 ─ Enrich backlog
Pass 2 ─ Seed fairness tracker
Pass 3 ─ Build reservation fences
Pass 4 ─ Generate candidates
Pass 5 ─ Filter hard constraints
Pass 6 ─ Score candidates (parallel) ← Rayon parallelism
Pass 7 ─ Sort descending by score
Pass 8 ─ Greedy allocation
└─ Build unfulfilled report
Pass 1 — Enrich backlog¶
Every demand is enriched with backlog_days before any scoring. This single field
drives both the aging component and the anti-starvation logic in later passes.
Pass 2 — Seed fairness tracker¶
The FairnessTracker records total demand quantity per fairness group before any
allocation occurs. This establishes the target shares against which actual
allocations are measured throughout the greedy pass.
Group field is configurable: channel, customer_class, program, or region.
Pass 3 — Build reservation fences¶
The ReservationEngine scans future demands within the lookahead_days window. For
each demand at or above min_protected_priority (default: PREMIUM), it creates a
fence on matching supply (same item + location), reserving
qty × (1 + safety_margin).
Fenced quantities are subtracted from the available supply pool before the greedy pass. Lower-priority demands that target fenced supply receive a score penalty rather than a hard block.
Pass 4 — Generate candidates¶
For each demand, the engine finds all compatible supply buckets:
- Item gate — supply must have the same
item_id - Location gate — supply must be at the same
location_id(prevents Cartesian explosion) - Date gate —
supply.available_date − demand.required_date ≤ max_delay_days - Routing check — if the demand has a
routing_policy_id, the policy is evaluated to mark the candidate feasible or infeasible and collect soft violations
Candidates are sorted deterministically by (demand_id, supply_id).
Pass 5 — Filter hard constraints¶
Candidates with hard routing violations (e.g. forbidden supply pool, disallowed
location when hard_mode = true) are removed. These demands are tracked to produce
accurate "why unfulfilled?" reasons.
Pass 6 — Score candidates (parallel)¶
Every feasible candidate receives a ScoreBreakdown computed by the ScoreEngine.
This pass runs in parallel via Rayon — FairnessTracker and ReservationEngine are
read-only snapshot inputs; no mutation occurs during scoring.
See Scoring Formula below.
Pass 7 — Sort descending¶
Highest score first. Ties broken deterministically by string order of IDs to guarantee reproducible results across runs.
Pass 8 — Greedy allocation¶
Iterates the sorted candidate list. For each candidate:
- Skip if supply remaining < ε or demand remaining < ε
- Skip if the demand's fairness group is hard-capped (optional)
- Compute
alloc_qty = min(demand_remaining, supply_remaining, allocatable_qty) - Deduct from both remaining maps
- Update
FairnessTrackerwith the allocated quantity - Record an
Allocationand anAllocationExplanationwith full score narrative
After the greedy pass, any demand with remaining_qty > ε becomes an
UnfulfilledDemand with one or more reason strings explaining why.
Scoring Formula¶
total_score =
priority_component ← base score from PriorityClass table
+ aging_component ← AgingPolicy(backlog_days) + starvation_bonus
+ lateness_component ← −LatenessCurve(supply_delay_days)
+ strategic_component ← demand.strategic_weight × global_factor
+ revenue_component ← demand.revenue_weight × global_factor
+ criticality_component ← demand.criticality_weight × global_factor
− routing_penalty ← RoutingPolicy soft violations
− fairness_penalty ← FairnessTracker over-share penalty
− reservation_penalty ← ReservationEngine fence penalty
Higher total score → allocated first.
Figure: each (demand, supply) candidate is scored in parallel; positive components and penalties combine into a total score that drives the deterministic greedy order, with fairness adjustments applied as allocation proceeds.
flowchart TD
C["Candidate<br/>(demand, supply)"] --> SC["Score engine (Rayon)"]
SC --> P["+ priority + aging + lateness<br/>+ strategic + revenue + criticality"]
SC --> N["− routing − fairness − reservation penalties"]
P --> T["total_score"]
N --> T
T --> SO["Sort DESC<br/>(tiebreak demand_id, supply_id)"]
SO --> G["Greedy allocation"]
G --> F["FairnessTracker adjusts share"]
Priority component¶
| PriorityClass | Default score |
|---|---|
AOG |
10 000 |
CRITICAL |
5 000 |
PREMIUM |
2 000 |
STANDARD |
1 000 |
LOW |
100 |
All values are configurable. The anti-starvation engine may escalate the effective
priority class by one level (e.g. STANDARD → PREMIUM) when auto_upgrade_priority
is enabled and the demand has waited beyond escalation_threshold_days.
Aging component¶
base = AgingPolicy.score(backlog_days)
bonus = StarvationEngine.starvation_bonus(demand)
aging = base + bonus
Three aging modes:
| Mode | Formula |
|---|---|
none |
0 |
linear |
multiplier × backlog_days |
exponential |
multiplier × backlog_days ^ exponent |
Starvation bonus kicks in when backlog_days ≥ escalation_threshold_days:
excess = backlog_days − escalation_threshold_days
bonus = min(excess × score_per_extra_day, max_starvation_bonus)
Default threshold: 14 days. Default score_per_extra_day: 50. Default cap: 3 000.
This means a STANDARD demand that has been waiting 28 days (14 days above threshold)
gains a bonus of 14 × 50 = 700 — enough to beat a fresh STANDARD demand but not
an AOG demand (which scores 10 000).
Lateness component¶
Penalizes supply that arrives later than the demand's due date. Uses a
piecewise-linear LatenessCurve:
delay = max(0, supply.available_date − demand.required_date)
lateness_component = −curve.interpolate(delay) ← subtracted from total
Default curve breakpoints: [(0, 0), (5, 10), (10, 50), (20, 500)]
Earlier supply is more attractive. A supply arriving exactly on time has zero lateness penalty; a supply arriving 20+ days late loses 500 points.
Business weight components¶
strategic_component = demand.strategic_weight × strategic_weight_factor
revenue_component = demand.revenue_weight × revenue_weight_factor
criticality_component = demand.criticality_weight × criticality_weight_factor
These weights are sourced from master.item and amplified by global multipliers
(default 1.0, configurable per scenario or segment).
Routing penalty¶
Each soft routing violation incurs a fixed penalty (default 100). Hard violations were already removed in Pass 5 — soft violations deduct score but allow the allocation to proceed if no better option exists.
Fairness penalty¶
When a group's actual allocation share exceeds target_share + tolerance:
overshoot = actual_share − target_share − tolerance
fairness_penalty = penalty_per_over_unit × overshoot × proposed_qty
With hard_cap = true, the group is entirely blocked once it exceeds its cap.
Reservation penalty¶
If the supply has a fence for a higher-priority future demand, the attempting demand
incurs ReservationConfig.penalty (default 2 000) deducted from its score. This is
a soft deduction — a highly urgent starved demand can still win despite the penalty.
Unfulfilled Demand Reasons¶
After the greedy pass, every demand with remaining quantity receives a reason string:
| Condition | Reason |
|---|---|
| No supply exists for the item/location | "No matching supply available for this item/location" |
| All candidates had hard routing violations | "All supply candidates blocked by hard routing/pool constraints" |
| Supply was consumed by higher-priority demands | "Insufficient supply after higher-priority allocations" |
| Demand is also starving | "Demand has been waiting N days (starvation threshold: Nd)" |
Multiple reasons can apply simultaneously.
Outputs¶
Each run produces three result sets written to ClickHouse:
Figure: after the greedy pass, demands with remaining quantity split off into the unfulfilled backlog while filled demands and all explanations are written to their respective ClickHouse tables.
flowchart LR
G["Greedy pass"] --> Q{"demand remaining > 0?"}
Q -- "no" --> A["PIPE_allocation_results<br/>(qty + score breakdown)"]
Q -- "yes" --> B["PIPE_allocation_unfulfilled<br/>(shortfall + reasons)"]
A --> X["PIPE_allocation_explanations<br/>(narrative steps)"]
B --> X
| Table | Content |
|---|---|
PIPE_allocation_results |
One row per allocation: demand_id, supply_id, qty, full score breakdown |
PIPE_allocation_unfulfilled |
One row per unmet demand: requested/allocated/shortfall qty, reasons array |
PIPE_allocation_explanations |
Step-by-step narrative fragments per demand |
Aggregate metrics returned per run:
| Metric | Description |
|---|---|
fill_rate |
total_allocated_qty / total_demand_qty |
demands_fully_filled |
Count of demands with zero shortfall |
demands_partially_filled |
Count of demands with partial allocation |
demands_unfilled |
Count of demands with zero allocation |
candidates_generated |
Total (demand × supply) pairs before filtering |
candidates_after_hard_filter |
Pairs remaining after routing hard blocks |
elapsed_ms |
Pure Rust engine wall time |
Example: Scoring Walkthrough¶
Two demands compete for 50 units of supply S1:
| D_AOG | D_STD_STALE | |
|---|---|---|
| Priority | AOG | STANDARD |
| Backlog days | 2 | 28 |
| Starvation threshold | 14 | 14 |
D_AOG score:
priority = 10 000 (AOG)
aging = 0 (2 days backlog, below threshold)
lateness = 0 (supply on time)
total = 10 000
D_STD_STALE score:
priority = 1 000 (STANDARD)
aging base = 0 (no aging policy assigned)
starvation = (28−14) × 50 = 700 bonus
total = 1 700
D_AOG wins: 10 000 > 1 700. The AOG demand is allocated first.
If starvation score_per_extra_day were raised to 650:
Related Pages¶
- Allocation Parameters — full parameter reference for all 7 policy types with JSON examples
- Allocation Engine Internals — Rust struct definitions, PyO3 bridge, CH schema, build instructions
- User Guide — Allocation — UI walkthrough for planners