Skip to content

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

backlog_days = max(0, current_date − required_date)

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:

  1. Item gate — supply must have the same item_id
  2. Location gate — supply must be at the same location_id (prevents Cartesian explosion)
  3. Date gatesupply.available_date − demand.required_date ≤ max_delay_days
  4. 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

sort by: total_score DESC, demand_id ASC, supply_id ASC

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:

  1. Skip if supply remaining < ε or demand remaining < ε
  2. Skip if the demand's fairness group is hard-capped (optional)
  3. Compute alloc_qty = min(demand_remaining, supply_remaining, allocatable_qty)
  4. Deduct from both remaining maps
  5. Update FairnessTracker with the allocated quantity
  6. Record an Allocation and an AllocationExplanation with 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:

starvation  =  (28−14) × 650 = 9 100
D_STD_STALE total = 1 000 + 9 100 = 10 100  > 10 000
The starved Standard demand would now win — demonstrating how the starvation threshold and score-per-day interact with the priority scale.