Stock Value vs Max Budget KPI — Investigation & Fix

Date: 2026-06-29  |  Scope: Mirabelle scenario-id consolidation + Dashboard IO KPI fix  |  Markdown: io-stock-value-vs-max-budget.md
  1. Symptom
  2. Investigation
  3. Root cause
  4. The fix
  5. Files changed
  6. Validation
  7. Known follow-ups
  8. Out of scope

1. 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) hidden

2. Investigation

Step 1 — Trace the KPI source

The Dashboard IO tab's "Stock Value" KPI is computed at read time as:

stock_value = Σ (committed_buffer × unit_cost)

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:

MetricDefinitionSource
Stock Value (KPI)Σ committed_buffer × unit_cost — absolute total, recomputed at read timeread-time aggregation
achieved_budget (optimizer)cumulative net investment: Σ (final − initial) buffer × unit_cost, via target.rs commit_groupsstored optimizer output
max_budgetinventory-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.

3. Root cause

Three distinct issues combined to produce the symptom:

  1. Missing columns in the heatmap pre-fetch. /api/meio/fr-heatmap selected only group_name, segment_id, achieved_fill_rate, fill_rate_target — not achieved_budget or max_budget. The Budget Cap chip's maxBudget gate (kpis?.maxBudget != null) was therefore always false, so the chip never rendered.
  2. 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_budget is a cap, not a live KPI.
  3. Vague label. "Stock Value" did not convey the safety-stock-investment semantics that make it comparable to max_budget.

4. The fix

A. Stock Value vs Max Budget KPI production fix

Files: files/api/main.py, files/frontend/src/components/Dashboard.jsx

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
 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).

B. Mirabelle scenario-id consolidation single source of truth

Problem: The PIPE_* table → scenario-type mapping was duplicated across three places that had already diverged:

Files: files/mirabelle/config.py, files/mirabelle/service.py, files/mirabelle/sql_generator.py

# 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"

5. Files changed

FileChange
files/api/main.pyHeatmap 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.jsxBudget Cap chip numerator uses achieved_budget_abs ?? achieved_budget; KPI label Stock ValueSafety Stock Value.
files/mirabelle/config.pyNew 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.

6. Validation

7. Known follow-ups

A second review pass found three new issues that are not yet fixed.
  1. Partition mismatch in segment_membership mapfiles/api/main.py:27947: the segment_membership map is pinned to max(archive_date), but the PIPE_meio_results rows feeding achieved_budget_abs have no archive_date filter. A partition mismatch can drop SKUs in edge cases (e.g. MEIO re-run without re-segmentation), so the absolute sum under-counts. The archive_date filter should be applied consistently to both sides of the join.
  2. Untracked config modulesfiles/main.py:294 / files/alerts/engine.py:17: utils/server_config.py and alerts/config.py are untracked in git. Deploying from a clean checkout raises ModuleNotFoundError (server_config) or silently defeats env config (alerts). They must be git added before commit.
  3. allocation_cover_weeks silent default flipfiles/supply/src/engine.rs:124: allocation_cover_weeks defaults to 0, silently flipping every existing pipeline from the old lead_time + 3 cover 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.

8. Out of scope