Skip to content

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

  1. Symptom
  2. Investigation
  3. Root cause
  4. The fix
  5. Files changed
  6. Validation
  7. Known follow-ups
  8. Out of scope

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:

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:

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:

  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.


The fix

A. Stock Value vs Max Budget KPI

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

  • Added achieved_budget, max_budget to 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_id map fetch from PIPE_segment_membership, guarded by if _pre_group_results: and scoped to archive_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_cost from 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 ValueSafety Stock Value.

  • Preserved the optimizer's stored achieved_budget for 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_TYPE and SCENARIO_COL maps to config.py as the single source of truth.
  • service.py: _TABLE_SCENARIO_TYPE / _SCENARIO_COL are now thin aliases of config.TABLE_SCENARIO_TYPE / config.SCENARIO_COL.
  • sql_generator.py: _build_system_prompt() now generates the "SCENARIO_ID PER TABLE" per-type table list from config.TABLE_SCENARIO_TYPE / config.SCENARIO_COL instead 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 ValueSafety 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_TYPE resolved from both service.py and sql_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_budget once the heatmap endpoint returns the new columns; numerator uses achieved_budget_abs so 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:

  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.


Out of scope

  • MEIO committed_buffer recalculation (unchanged — remains the SS source of truth).
  • The optimizer's target.rs commit_groups net-investment accounting (preserved as-is).
  • max_budget parameter-set editor UI (no schema change).
  • Frontend chart rendering on the IO tab.