~/About~/Systems~/Foundry~/Blueprint~/Journal~/Projects
Book a Call
Journal

Why I Froze Simulation Inputs Before the Solver Ran

From the Inventory Allocation Simulator system

·5 min read·Kingsley Onoh·View on GitHub

Project

Inventory Allocation Simulator

Proof type

Technical proof

Best for

Senior engineer

Source

GitHub available

Inspect

Code SampleFailureConstraintSurprise

A planner can approve the right transfer for the wrong reason.

That was the failure mode I kept coming back to while building the Inventory Allocation Simulator. The solver could be mathematically correct, the recommendation could have positive net value, and the UI could show a clean explanation. But if the explanation reread today's warehouse and SKU tables after yesterday's simulation finished, the audit trail would be fiction.

Inventory data changes constantly. A lane gets disabled. A SKU margin changes. Inbound units arrive. Demand history is corrected because a stockout period was recorded as zero sales. If a completed simulation depends on the current state of those tables, its story changes every time the business updates its planning data.

I was wrong to treat that as a reporting problem at first. It was a data contract problem.

The failure hiding in a normal design

The first design looked harmless: store a simulation run, run the worker, persist recommendations, and render the detail page by joining back to warehouses, SKUs, inventory, lanes, and policies. Most CRUD systems are written that way because joins are cheap and normalized tables keep data clean.

That design breaks the moment a simulation becomes evidence.

The recommendation is not just a row saying transfer 30 units. It needs to explain the constraint that bound the decision, the demand scenario that created the shortage, the service-level tail left unmet, and the tradeoffs accepted. In this project those fields live in explanation: binding_constraints, scenario_sensitivity, accepted_tradeoffs, net_value, and solver diagnostics.

If the explanation uses live tables, a planner can open the same completed run on Tuesday and see different supporting facts from Monday. That is worse than no explanation. It creates confidence in a record that no longer matches the decision.

The constraint I chose

I made simulation creation the boundary.

create_simulation_run! authorizes the planner, validates the scenario count, captures every planning surface needed by the solver, and stores it inside simulation_runs.input_snapshot. The worker consumes that snapshot. The detail page reads that snapshot. Demand scenarios are stored with the run. Completed runs do not ask the mutable catalog what the world looks like now.

The core function is small, which is the point:

function capture_simulation_input_snapshot(
    store::AbstractTenantAdminStore,
    ctx::TenantContext,
    policy_id,
)::NamedTuple
    authorize!(ctx, "run_cancel", "simulation")
    parsed_policy_id = _uuid_value(policy_id)
    policy = _snapshot_policy(store, ctx, parsed_policy_id)
    return (
        tenant_id = string(ctx.tenant_id),
        policy = policy,
        warehouses = [_warehouse_response(row) for row in fetch_warehouses(store, ctx.tenant_id, _snapshot_page())],
        skus = [_sku_response(row) for row in fetch_skus(store, ctx.tenant_id, _snapshot_page())],
        inventory_positions = [_inventory_response(row) for row in fetch_inventory_positions(store, ctx.tenant_id, _snapshot_page())],
        demand_history = [_demand_response(row) for row in fetch_demand_history(store, ctx.tenant_id, _snapshot_page())],
        transfer_lanes = [_lane_response(row) for row in fetch_transfer_lanes(store, ctx.tenant_id, _snapshot_page())],
    )
end

That snapshot is not elegant. It is intentionally blunt. The run carries the policy, warehouses, SKUs, inventory positions, demand history, and transfer lanes it used. SNAPSHOT_MAX_ROWS is set to 1_000_000, which tells you the tradeoff plainly: this is a batch planning system, not a real-time transfer executor.

What surprised me

The snapshot decision also fixed a forecasting bug before it could become a solver bug.

Stockout periods are dangerous because they make demand look low. In clean_demand_history, the system stores both observed units and adjusted units. If demand was zero and lost sales were 82, the cleaned demand is 82. That value feeds the scenario generator. The stockout row also inflates uncertainty, because a period with unavailable inventory is less trustworthy than normal sales.

The Batch 019 test mutates live inventory and demand after a run is created. Then it runs the worker and checks that the scenario baseline still comes from the frozen snapshot, not the updated live row. That was the test that made the architecture feel real. It did not just prove a function. It proved the system can remember what it believed when the recommendation was created.

The alternative was versioning every table. That would give better diff history, but it would also make the MVP harder to operate. I chose the snapshot because the project needed completed-run honesty more than a general temporal database. A future version could move to event-sourced planning records. This one needed a concrete audit boundary.

Where the solver fits

The solver reads the frozen snapshot and stored scenarios, then builds a JuMP model over lanes, SKUs, inventory, service level, warehouse capacity, transfer cost, and safety stock. The model is allowed to fail. In fact, readable failure is part of the contract.

A region rule can block every feasible transfer. A max transfer cost can make the plan infeasible. A timeout can return no acceptable incumbent. Those cases return diagnostics instead of pretending every scenario has a transfer.

That mattered for the Journal topic because failure diagnostics have to describe the same world the solver saw. The _constraint_report function can name max_transfer_cost_cents, region blocking, sender safety stock, and receiver service-level constraints. If those facts came from live tables, the failure message could drift too.

The result

The deterministic solver fixture produces a 30-unit transfer from WH-SURPLUS to WH-NEED, with lane_capacity and receiver_service_level as binding constraints and a 30,900-cent net value. The large benchmark ran 50 warehouses, two thousand SKUs, and 100 scenarios in 17,928.4753 ms and generated two thousand recommendations.

Those numbers matter less than the contract behind them. A completed run is not a report over current data. It is a preserved decision record. Once I made that boundary explicit, the rest of the system had somewhere honest to stand.

#julia#optimization#simulation#auditability#supply-chain

The architecture behind this essay for Inventory Allocation Simulator

Get Notified

New system breakdown? You'll know first.