Stock Value vs Max Budget KPI — Investigation & Fix¶
Date: 2026-06-29
Scope: Mirabelle scenario-id consolidation + Dashboard IO KPI fix (files/api/main.py, files/frontend/src/components/Dashboard.jsx, files/mirabelle/*)
Companion page: io-stock-value-vs-max-budget.html
TOC¶
Symptom¶
On the Dashboard → IO tab, the "Stock Value" KPI showed 4.4M while the IO
parameter's max_budget was 15,000. The user expected these two numbers to be
directly comparable — they were both served from the same IO parameter set, so the
300× gap looked like a bug.
| Field (before fix) | Value |
|---|---|
| "Stock Value" KPI (Dashboard IO tab) | 4,400,000 |
max_budget (IO parameter-set field) |
15,000 |
| Budget Cap chip (next to KPI) | not rendered (hidden) |
Investigation¶
Step 1 — Trace the KPI source¶
The Dashboard IO tab's "Stock Value" KPI is computed at read time as:
summed over every (item_id, site_id) row in PIPE_meio_results for the active
pipeline. The value is an absolute total of safety-stock investment.
Step 2 — Trace the Budget Cap chip¶
The Budget Cap chip (Dashboard.jsx:4238) renders only when
kpis?.maxBudget != null. That field is populated from the /api/meio/fr-heatmap
endpoint, whose group-results pre-fetch in main.py:~27710 selected:
SELECT group_name, segment_id, achieved_fill_rate, fill_rate_target
FROM PIPE_meio_group_results
WHERE pipeline_id = %s AND scenario_id = %s
— only four columns, never achieved_budget or max_budget. So maxBudget
was always null, the chip was always hidden, and the only 15,000 the user ever
saw was the configured parameter-set field value, not a live KPI.
Step 3 — Are the two metrics even comparable?¶
Even if both were served, they are not the same metric:
| Metric | Definition | Source |
|---|---|---|
| Stock Value (KPI) | Σ committed_buffer × unit_cost — absolute total, recomputed at read time |
read-time aggregation |
achieved_budget (optimizer) |
cumulative net investment: Σ (final − initial) buffer × unit_cost, via target.rs commit_groups |
stored optimizer output |
max_budget |
inventory-investment cap the optimizer respects (default 1e12 = unlimited) |
parameter-set field |
achieved_budget equals the absolute total only on a fresh run (initial
buffers = 0). After any re-run with non-zero initial buffers, the optimizer's
stored value is a delta, not an absolute. So comparing Stock Value directly
to achieved_budget would be wrong in steady state.
Step 4 — Label clarity¶
The label "Stock Value" was vague — it did not convey that the figure is
safety-stock investment, i.e. the same quantity the max_budget cap is meant
to bound.
Root cause¶
Three distinct issues combined to produce the symptom:
-
Missing columns in the heatmap pre-fetch.
/api/meio/fr-heatmapselected onlygroup_name, segment_id, achieved_fill_rate, fill_rate_target— notachieved_budgetormax_budget. The Budget Cap chip'smaxBudgetgate (kpis?.maxBudget != null) was therefore always false, so the chip never rendered. -
Metric mismatch. Even when both values are served, Stock Value (absolute total) and the optimizer's
achieved_budget(net delta) are not directly comparable except on a fresh run.max_budgetis a cap, not a live KPI. -
Vague label. "Stock Value" did not convey the safety-stock-investment semantics that make it comparable to
max_budget.
The fix¶
A. Stock Value vs Max Budget KPI¶
Files: files/api/main.py, files/frontend/src/components/Dashboard.jsx
- Added
achieved_budget, max_budgetto the heatmap group_results SELECT so the Budget Cap chip can render:
SELECT group_name, segment_id, achieved_fill_rate, fill_rate_target,
achieved_budget, max_budget
FROM PIPE_meio_group_results
WHERE pipeline_id = %s AND scenario_id = %s
-
Added a
(item_id, site_id) → segment_idmap fetch fromPIPE_segment_membership, guarded byif _pre_group_results:and scoped toarchive_date = (SELECT max(archive_date) ...)to avoid a full-history scan. This lets per-group absolute inventory value be computed. -
Computed per-group
achieved_budget_abs = Σ committed_buffer × unit_costfrom the heatmap's fetched rows, using the same unit-cost fallback chain the heatmap rows loop uses:
# same fallback as the heatmap rows loop
achieved_budget_abs += ss_v_db if ss_v_db > 0 else (buf * ucost)
This makes the chip numerator directly comparable to the Safety Stock Value KPI
(both are absolute totals over the same (item_id, site_id) set).
-
Frontend chip numerator now uses
achieved_budget_abs ?? achieved_budget— falls back gracefully to the optimizer's stored delta when the absolute computation is unavailable. -
Renamed label
Stock Value→Safety Stock Value. -
Preserved the optimizer's stored
achieved_budgetfor the diagnostic/completion view (not deleted, just no longer the chip numerator).
B. Mirabelle scenario-id consolidation¶
Problem: The PIPE_* table → scenario-type mapping was duplicated across three
places that had already diverged:
- _TABLE_SCENARIO_TYPE in service.py
- the LLM prompt rules in sql_generator.py (missing PIPE_allocation_explanations,
PIPE_backtest_fingerprints, PIPE_series_hashes)
- AGENTS.md
Files: files/mirabelle/config.py, files/mirabelle/service.py,
files/mirabelle/sql_generator.py
- Moved
TABLE_SCENARIO_TYPEandSCENARIO_COLmaps toconfig.pyas the single source of truth. service.py:_TABLE_SCENARIO_TYPE/_SCENARIO_COLare now thin aliases ofconfig.TABLE_SCENARIO_TYPE/config.SCENARIO_COL.sql_generator.py:_build_system_prompt()now generates the "SCENARIO_ID PER TABLE" per-type table list fromconfig.TABLE_SCENARIO_TYPE/config.SCENARIO_COLinstead of a hardcoded copy. The three previously divergent tables now appear in the prompt.
# config.py — single source of truth
TABLE_SCENARIO_TYPE = {
"PIPE_meio_results": "meio_scenario_id",
"PIPE_supply_orders": "supply_scenario_id",
"PIPE_causal_forecast": "causal_scenario_id",
# ... every PIPE_* table
}
SCENARIO_COL = "scenario_id"
Files changed¶
| File | Change |
|---|---|
files/api/main.py |
Heatmap group_results SELECT gains achieved_budget, max_budget; (item_id, site_id) → segment_id map fetch from PIPE_segment_membership (scoped to max(archive_date)); per-group achieved_budget_abs = Σ committed_buffer × unit_cost with heatmap's unit-cost fallback. |
files/frontend/src/components/Dashboard.jsx |
Budget Cap chip numerator uses achieved_budget_abs ?? achieved_budget; KPI label Stock Value → Safety Stock Value. |
files/mirabelle/config.py |
New home for TABLE_SCENARIO_TYPE and SCENARIO_COL (single source of truth). |
files/mirabelle/service.py |
_TABLE_SCENARIO_TYPE / _SCENARIO_COL now alias config.TABLE_SCENARIO_TYPE / config.SCENARIO_COL. |
files/mirabelle/sql_generator.py |
_build_system_prompt() builds the per-type table list from config.TABLE_SCENARIO_TYPE / config.SCENARIO_COL instead of a hardcoded copy. |
Validation¶
- Import chain:
files/mirabelle/imports load cleanly —config.TABLE_SCENARIO_TYPEresolved from bothservice.pyandsql_generator.py. - Generated prompt: the LLM system prompt now contains all expected PIPE_*
tables and their correct scenario columns, including the three previously
missing (
PIPE_allocation_explanations,PIPE_backtest_fingerprints,PIPE_series_hashes). - Dashboard chip: Budget Cap chip renders with
max_budgetonce the heatmap endpoint returns the new columns; numerator usesachieved_budget_absso it is directly comparable to the Safety Stock Value KPI. - Label: "Safety Stock Value" now conveys the safety-stock-investment semantics.
Known follow-ups¶
A second review pass found three new issues that are not yet fixed:
-
Partition mismatch in segment_membership map —
files/api/main.py:27947: the segment_membership map is pinned tomax(archive_date), but thePIPE_meio_resultsrows feedingachieved_budget_abshave noarchive_datefilter. A partition mismatch can drop SKUs in edge cases (e.g. MEIO re-run without re-segmentation), so the absolute sum under-counts. Thearchive_datefilter should be applied consistently to both sides of the join. -
Untracked config modules —
files/main.py:294/files/alerts/engine.py:17:utils/server_config.pyandalerts/config.pyare untracked in git. Deploying from a clean checkout raisesModuleNotFoundError(server_config) or silently defeats env config (alerts). They must begit added before commit. -
allocation_cover_weekssilent default flip —files/supply/src/engine.rs:124:allocation_cover_weeksdefaults to0, silently flipping every existing pipeline from the oldlead_time + 3cover buffer to exactly-safety-stock sizing. This is a service-level behavior change with no opt-in — existing pipelines that relied on the forward cover will see smaller transfer orders with no warning. Either preserve the legacy default or expose an explicit migration flag.
Out of scope¶
- MEIO
committed_bufferrecalculation (unchanged — remains the SS source of truth). - The optimizer's
target.rs commit_groupsnet-investment accounting (preserved as-is). max_budgetparameter-set editor UI (no schema change).- Frontend chart rendering on the IO tab.