diff --git a/PRPs/PRP-26-scenario-simulation-what-if-planning.md b/PRPs/PRP-26-scenario-simulation-what-if-planning.md new file mode 100644 index 00000000..c13e28dd --- /dev/null +++ b/PRPs/PRP-26-scenario-simulation-what-if-planning.md @@ -0,0 +1,1047 @@ +name: "PRP-26 — Scenario Simulation / What-If Planning (MVP)" +description: | + Context-rich PRP that promotes the **MVP scope** of + `docs/optional-features/03-scenario-simulation-what-if-planning.md` into code: a + new `app/features/scenarios/` vertical slice that turns ForecastLabAI from + "predict the future" into "plan possible futures". It runs a baseline forecast + from an existing trained model, applies **deterministic, transparent uplift / + drag factors** for future assumptions (price change, promotion, holiday, + inventory, lifecycle), and returns a baseline-vs-scenario comparison. Scenarios + can be saved as named JSON plans. A new `Visualize → What-If Planner` page drives + the slice. Phased so each phase is independently shippable and one-pass + implementable. + +## Purpose + +ForecastLabAI can train, predict, backtest, register and visualise demand — but +every forecast answers exactly one question: *"what happens if nothing changes?"* +There is **no surface that answers "what if we discount this SKU 15% next week?"** +This PRP delivers the **MVP** of the Scenario Simulation feature brief: + +- **Phase A — Stateless Simulation Engine (backend)**: a pure deterministic + adjustment engine (`adjustments.py`) and a stateless `POST /scenarios/simulate` + endpoint that resolves a baseline model, runs a baseline forecast, applies + per-day adjustment factors, and returns a `ScenarioComparison`. No table yet. +- **Phase B — Saved Scenario Plans (persistence)**: a new `scenario_plan` table + + Alembic migration, and `POST /scenarios` / `GET /scenarios` / + `GET /scenarios/{id}` / `DELETE /scenarios/{id}` CRUD over saved plans. +- **Phase C — What-If Planner Page (frontend)**: a `/visualize/planner` page — + baseline picker → assumption form → run → baseline-vs-scenario chart + delta + table → save / reload / delete named plans. + +The **"Full Version"** (a future-feature-frame generator, exogenous-regressor +model support, agent-generated scenarios, multi-scenario comparison) is explicitly +**out of scope** — see DECISIONS LOCKED #1. + +> Source plan: `.agents/plans/scenario-simulation-what-if-planning.md` (validated +> against the repo as of 2026-05-19). Feature brief: +> `docs/optional-features/03-scenario-simulation-what-if-planning.md`. + +--- + +## DEPENDS ON — read before starting + +This PRP has **no new dependency** on an unmerged PRP. It builds on already-merged +slices: `forecasting` (PRP-5), `registry` (PRP-7), `jobs` (PRP-8), +`data_platform` (PRP-2), and the frontend dashboard (PRP-11). Sanity-check before +starting: if `app/features/forecasting/service.py` does not define +`ForecastingService.predict` and `app/features/forecasting/persistence.py` does +not expose `load_model_bundle`, stop — a dependency moved and the artifact-resolution +plan needs revisiting. + +--- + +## Goal + +**Feature Goal**: Ship the MVP of Scenario Simulation as a new +`app/features/scenarios/` vertical slice — the first slice since `rag` to ship +both a persisted table **and** a non-trivial compute path — plus a `Visualize → +What-If Planner` page, delivered as three independently-shippable phases. + +**Deliverable**: +- **Backend** — a new `scenarios` slice (`models.py`, `schemas.py`, + `adjustments.py`, `service.py`, `routes.py`, `tests/`), one Alembic migration + creating `scenario_plan`, and five endpoints (`POST /scenarios/simulate`, + `POST /scenarios`, `GET /scenarios`, `GET /scenarios/{scenario_id}`, + `DELETE /scenarios/{scenario_id}`). +- **Frontend** — a `/visualize/planner` page, `use-scenarios.ts` hooks, a pure + `scenario-utils.ts` module (+ vitest), new `Scenario*` TS types, a route + nav + entry. + +**Success Definition**: `docker compose up` → seed → `make demo` (so completed +`predict` jobs + trained models exist) → open `/visualize/planner` → a planner +picks a baseline `predict` job, defines assumptions (e.g. −15% price + a `pct_off` +promotion), runs a simulation, sees a baseline-vs-scenario chart + delta table + +a visible **heuristic disclaimer**, exports the delta CSV, and saves / reloads / +deletes a named plan — with every gate (`ruff`, `mypy --strict`, +`pyright --strict`, `pytest` unit + integration, frontend `tsc`/`lint`/`test`) +green. + +## Why + +- **User value** — business users can quantify the demand + revenue impact of a + decision (a discount, a promotion, a holiday) *before* committing to it, and + save the analysis as a reusable plan. Inventory users get a coverage / stockout + verdict under demand spikes. +- **Demo value** — a demo reviewer currently sees a *forecasting* system; this + feature makes it a *planning* system — a recognisably high-value retail + workflow. +- **Integration** — the data platform already models the relevant drivers (price + history, promotions, inventory snapshots, calendar/holiday flags) and Phase-2 + feature engineering already supports promotion / lifecycle / exogenous features; + none of that was reachable as a planning workflow. This slice surfaces it. + +## What + +### User-visible behavior + +A new `Visualize → What-If Planner` page (`/visualize/planner`) lets a planner: + +1. **Pick a baseline** — choose a completed `predict` job (its `run_id` is the + baseline model artifact key) and a horizon (7 / 14 / 30 / 60 / 90 days). +2. **Define assumptions** — all optional: a price `change_pct` over a date window, + a promotion `kind` + window, holiday/event dates, an inventory `on_hand_units` + cap, a lifecycle `stage` override. +3. **Run the simulation** — `POST /scenarios/simulate` returns a baseline series, + a scenario series, per-day + aggregate deltas, a revenue delta, and a coverage + verdict. +4. **Review** — a baseline-vs-scenario two-series chart, KPI tiles + (units/revenue delta, coverage verdict), a per-day delta table with CSV export, + and a **prominent heuristic-disclaimer banner**. +5. **Save / reload / delete** — persist the scenario as a named plan (inputs + + the comparison snapshot), list saved plans, reload one, delete one. + +### Technical requirements + +- Phase A: a new `scenarios` slice — pure `adjustments.py`, Pydantic v2 request + + response models, `ScenarioService.simulate`, an `APIRouter`, RFC 7807 errors, + `mypy --strict` + `pyright --strict` clean. **Stateless** — no table. +- Phase B: a `scenario_plan` ORM model (JSONB inputs + JSONB comparison snapshot), + an Alembic migration (upgrade **and** downgrade), CRUD service methods + routes. +- Phase C: **frontend only** — a pure `scenario-utils.ts` (+ vitest), TanStack + Query hooks, the planner page, routing + nav wiring. No backend changes. +- No new external dependency, no managed-cloud SDK, no WebSocket. Reuses FastAPI, + SQLAlchemy 2.0 async, Pydantic v2, numpy, structlog, Recharts, TanStack Query — + all already present. + +### Success Criteria + +- [ ] `POST /scenarios/simulate` returns a `ScenarioComparison` with `points` + length == `horizon`, baseline + scenario series, per-day + aggregate deltas, + a revenue delta, a `coverage_verdict`, and `method == "heuristic"` plus a + non-empty `disclaimer`. +- [ ] An empty `ScenarioAssumptions` yields scenario == baseline, all deltas 0.0. +- [ ] A bogus `run_id` → RFC 7807 problem response (404/400), never a 500. +- [ ] `adjustments.py` helpers are pure, never raise on junk input, and are + unit-tested directly. +- [ ] `test_leakage.py` proves the scenario adjustment touches only horizon + (future) points and never mutates / reads the historical series — treated as + a load-bearing spec (never weakened to make a feature pass). +- [ ] `POST /scenarios` persists a plan; `GET /scenarios` lists; `GET + /scenarios/{id}` returns the embedded comparison snapshot; `DELETE` removes + it; `GET /scenarios` on an empty table → 200 + empty list (never 404). +- [ ] The Alembic migration creates `scenario_plan` and upgrades **and** + downgrades cleanly on a fresh DB. +- [ ] `Visualize → What-If Planner` lets a user pick a baseline `predict` job, + define assumptions, run a simulation, see a baseline-vs-scenario chart + + delta table + a visible heuristic disclaimer, export the delta CSV, and + save / reload / delete a named plan. +- [ ] All gates pass: `ruff`, `mypy --strict`, `pyright --strict`, `pytest` + (unit + integration), frontend `tsc` + `lint` + `test`. +- [ ] No new external dependency; no managed-cloud SDK; no WebSocket; the slice + respects the no-cross-slice-service-import rule (DECISIONS LOCKED #2). +- [ ] README + `docs/_base/{API_CONTRACTS,REPO_MAP_INDEX,DOMAIN_MODEL}.md` updated. + +--- + +## All Needed Context + +### DECISIONS LOCKED (resolved during planning — do NOT re-litigate) + +1. **MVP scope only — the heuristic adjustment is a post-forecast multiplier.** + The baseline models (`naive`, `seasonal_naive`, `moving_average`) forecast from + the historical target series only and **ignore the exogenous `X` argument** + (verified: `# noqa: ARG002` on every `fit`/`predict` in + `forecasting/models.py`). The MVP therefore applies assumptions as a + **deterministic post-forecast multiplier** on the baseline forecast — never a + leakage-prone re-training. Every result is explicitly labelled + `method = "heuristic"` with a fixed `disclaimer` string. The "Full Version" + (future-feature-frame generator, exogenous-regressor model support, + agent-generated scenarios, multi-scenario comparison) is **out of scope** — it + needs models that consume future feature frames, which the MVP does not add. + +2. **The scenario service does NOT import a sibling slice's `service.py`.** + `AGENTS.md` § Architecture: "a slice may NOT import from another slice; + cross-cutting code goes through `app/core/` or `app/shared/`." The sanctioned + narrow exception (used by `ops`) is importing a sibling's **ORM `models.py`** + read-only — NOT its `service.py`. Calling `ForecastingService` from `scenarios` + would be a genuine cross-slice *service* import and **violates the rule**. + RESOLUTION: the scenario service imports only the **stable, lower-level + building blocks** — `load_model_bundle` from `forecasting/persistence.py` — and + produces the baseline forecast by calling `bundle.model.predict(horizon)` + directly (the `BaseForecaster` interface), replicating the ~30-line + `ForecastPoint`-construction block from `ForecastingService.predict` + (`forecasting/service.py`, the predict body). Read-only ORM imports of sibling + `models.py` (`data_platform`, `registry`) are allowed. Alternative considered + + rejected: promoting the predict logic to `app/shared/` — larger blast radius, + deferred to the Full Version. **This decision MUST be cited in the PR + description** per `product-vision.md` § "When Ideas Don't Align". + +3. **`scenario_plan` stores the comparison SNAPSHOT, not just the inputs.** A + saved plan persists both the raw `ScenarioAssumptions` **and** the full + `ScenarioComparison` as JSONB, so a reloaded plan re-renders without + recomputation (and without needing the original model artifact to still exist). + Persist via `model_dump(mode="json")` so `date`/`datetime` serialise to strings + (JSONB rejects Python `date`). + +4. **There is no `scenarios` commit scope.** The `.claude/rules/commit-format.md` + allow-list has **no `scenarios` scope**. Use `feat(api)` for the backend slice, + `feat(api,db)` for the slice + migration, `feat(ui)` for the frontend, + `test(api)` for backend tests, `docs(docs)` for docs. Do **not** invent a + scope. + +5. **The current Alembic head is `378c112e4b32`, NOT `d6e0f2g3h456`.** The source + plan guessed `d6e0f2g3h456`; the verified head (via `uv run alembic heads`) is + `378c112e4b32_create_app_config_table.py`. Set the new migration's + `down_revision = "378c112e4b32"` — but **re-verify with `uv run alembic heads` + immediately before writing the migration** (a PRP merging first would move it). + +6. **No WebSocket.** Simulation is request/response — `POST /scenarios/simulate` + computes synchronously and returns. No streaming surface — consistent with + `product-vision.md` "not a real-time streaming system". + +### Documentation & References + +```yaml +# ── MUST READ — repo files (read BEFORE implementing) ── + +- file: PRPs/PRP-25-forecastops-control-center-full.md + why: The most recent, highest-quality PRP. Mirror its DECISIONS LOCKED section, + its Known Gotchas table, its phased task list, its Anti-Patterns, and its + Confidence Score rationale. + +- file: PRPs/PRP-22-visualize-demand-planner.md + why: The immediate sibling — it added a new Visualize page (demand.tsx), a new + hook, a new pure util module, new TS types, a route + nav entry. The EXACT + frontend shape this feature reuses. Its Resolved Decisions + Known Gotchas + apply. + +- file: PRPs/PRP-9-rag-knowledge-base.md + why: The precedent for a slice that ships a new table + Alembic migration + + service compute path. Read its migration + model + service layering. + +- file: app/features/forecasting/service.py + why: ForecastingService.predict() is the baseline-forecast engine — loads a + .joblib bundle, validates store/product, calls bundle.model.predict(horizon), + builds ForecastPoints. The scenario service REPLICATES the ~30-line predict + body (per DECISIONS LOCKED #2) rather than importing this class. + critical: Path-traversal validation in predict() is load-bearing — mirror it. + +- file: app/features/forecasting/persistence.py + why: load_model_bundle — the SANCTIONED lower-level building block the scenario + service imports (NOT ForecastingService). Read what the bundle carries + (model, metadata with store_id/product_id/train_end_date). + +- file: app/features/forecasting/schemas.py + why: ForecastPoint (date, forecast, lower_bound?, upper_bound?) + PredictResponse + — the baseline series shape. TrainRequest is the request-body strict-mode + pattern (ConfigDict(strict=True) + Field(strict=False) on date fields). + +- file: app/features/forecasting/models.py + why: Confirms the baseline forecasters IGNORE the exogenous X argument + (# noqa: ARG002) — the reason the MVP is a post-forecast multiplier. + +- file: app/features/jobs/service.py + why: _execute_predict shows how a run_id resolves to a model artifact — + {artifacts_dir}/model_{run_id}.joblib, then load_model_bundle to read + store_id/product_id from bundle.metadata. The scenario service resolves the + baseline artifact the SAME way. + critical: A predict/train job's run_id is the ARTIFACT KEY (model_{run_id}.joblib), + NOT a registry model_run.run_id — see Known Gotchas. + +- file: app/features/jobs/schemas.py + why: JobCreate / JobResponse — the scenario page picks a completed predict job + whose result carries run_id, store_id, product_id, forecasts. + +- file: app/features/jobs/models.py + why: Job — JSONB columns for params/result, CheckConstraint, Index, + TimestampMixin. The scenario_plan table mirrors this. + +- file: app/features/registry/models.py + why: ModelRun (JSONB model_config/metrics; data_window_end; store_id/product_id; + run_id 32-char string; RunStatus). Read-only context lookup only. + +- file: app/features/data_platform/models.py + why: SalesDaily (store_id, product_id, date, quantity, unit_price) — used to + estimate a baseline unit price for the revenue-delta calc. PriceHistory / + Promotion (kind in {pct_off,bogo,bundle,markdown}, discount_pct) document + the real driver semantics the heuristic factors approximate. + +- file: app/features/ops/service.py + why: The pure module-scope helper pattern (extract_wape, score_retraining_candidate) + — exactly how adjustments.py helpers should be written (pure, never raise, + unit-tested directly). OpsService class shape mirrors ScenarioService. + +- file: app/features/ops/schemas.py + why: Response-model conventions — ConfigDict(from_attributes=True), every field + a Field(..., description=), counts ge=0, NO strict=True on response models. + +- file: app/features/ops/routes.py + why: APIRouter(prefix=, tags=), Query(default=, ge=, le=, description=), + Depends(get_db), rich docstrings. + +- file: app/features/rag/models.py + why: DocumentSource — the model-with-JSONB + TimestampMixin + String(32) + external-id + GIN-index pattern for the new scenario_plan table. + +- file: alembic/versions/37e16ecef223_create_jobs_table.py + why: The EXACT migration shape for a new JSONB-bearing table — op.create_table, + postgresql.JSONB(astext_type=sa.Text()), GIN index, CheckConstraint, + server_default=sa.text('now()') on timestamps, a real downgrade(). + +- file: app/features/ops/tests/conftest.py + why: Real-Postgres integration fixtures — ASGITransport client, FK-safe scoped + cleanup, TEST-/test- marker on every seeded natural key. The scenario + conftest MUST add delete(ScenarioPlan) to its cleanup. + +- file: app/features/ops/tests/test_service.py + why: The pattern for unit-testing pure helpers — adjustments.py gets the same. + +- file: app/features/ops/tests/test_routes_integration.py + why: The @pytest.mark.integration route-test pattern (happy + empty-DB + 422). + +- file: app/features/forecasting/tests/test_service.py + why: How ForecastingService is tested with a real model bundle on disk — needed + for the /scenarios/simulate integration test (it needs a trained model). + +- file: app/features/featuresets/tests/test_leakage.py + why: The leakage-spec precedent — a test that IS the spec, never weakened to + make a feature pass. The scenario test_leakage.py follows this philosophy. + +- file: app/core/problem_details.py + why: RFC 7807 application/problem+json envelope. The route layer maps + FileNotFoundError/ValueError from the service to structured problems. + +- file: frontend/src/pages/visualize/demand.tsx + why: The closest page — header, loading/error/empty early returns, Card/Table + sections, Select controls, useMemo derivations, a drill-in Card, CSV + export, formatNumber, @/ imports, keyboard-operable rows. The planner page + mirrors its skeleton. + +- file: frontend/src/pages/visualize/forecast.tsx + why: The in-page job-launch pattern — useCreateJob().mutateAsync(...), JobPicker, + a horizon Select, runError state, getErrorMessage. The planner reuses this + to pick a baseline predict job. + +- file: frontend/src/hooks/use-jobs.ts + why: useCreateJob (a useMutation) is the pattern for useSimulateScenario / + useCreateScenario / useDeleteScenario; useJobs({jobType:'predict', + status:'completed'}) lists baseline-candidate jobs. + +- file: frontend/src/hooks/use-ops.ts + why: The query-hook pattern (useQuery, queryKey array, api(path, {params})). + Mirror for useScenario / useScenarios. + +- file: frontend/src/lib/demand-utils.ts + why: The pure-util module pattern — typed, no React, no I/O, @/types/api + imports, fully unit-tested. scenario-utils.ts follows this exactly. + +- file: frontend/src/lib/csv-export.ts + why: toCsv/downloadCsv/CsvColumn — reuse for the delta-table export. + CSV-injection-safe; do NOT re-implement. + +- file: frontend/src/components/charts/time-series-chart.tsx + why: The Recharts wrapper — data, actualKey/predictedKey, showActual/showPredicted, + optional lowerKey/upperKey/showInterval band. The baseline-vs-scenario chart + renders TWO series here (baseline as actualKey, scenario as predictedKey). + critical: Verify the exact prop names in the file before wiring. + +- file: frontend/src/components/common/job-picker.tsx + why: JobPicker — reused verbatim to pick a baseline predict job. Also reuse + ErrorDisplay/EmptyState, LoadingState, StatusBadge from components/common/. + +- file: frontend/src/lib/constants.ts + why: ROUTES.VISUALIZE (FORECAST/BACKTEST/DEMAND) + the Visualize NAV_ITEMS + submenu — add PLANNER here. + +- file: frontend/src/App.tsx + why: The lazy(() => import()) block + the ROUTES.VISUALIZE.* block — + add the planner identically (copy the DEMAND route). + +- file: frontend/src/types/api.ts + why: Job, JobCreate, ForecastPoint, Product already defined — add Scenario* + interfaces here, mirroring the Ops* / InventoryStatus* additions. + +# ── Rules — read before writing any code ── + +- file: .claude/rules/product-vision.md + why: Principle 5 (time-safety), principle 8 (single-host), the "not a streaming + system" guardrail. Answer all 6 Litmus-Test questions in the PR description. + +- file: .claude/rules/security-patterns.md + why: Pydantic v2 at every boundary, SQLAlchemy parameter binding, + pathlib.Path.resolve() for the model-artifact path, the strict-mode + request-body policy. + +- file: .claude/rules/test-requirements.md + why: New module -> test file; new endpoint -> route test (2xx + 1 error path); + new model -> constraint test; new migration -> upgrade/downgrade clean. + +- file: .claude/rules/commit-format.md + why: type(scope): description (#issue). GOTCHA: no `scenarios` scope — see + DECISIONS LOCKED #4. + +- file: .claude/rules/branch-naming.md + why: branch feat/scenario-what-if-planner off dev. + +- file: .claude/rules/ui-design.md +- file: .claude/rules/shadcn-ui.md + why: Build the page via frontend-design + shadcn-ui skills; dogfood in a real + browser via webapp-testing / agent-browser. A green tsc is NOT proof the + UI works. + +# ── External documentation ── + +- url: https://fastapi.tiangolo.com/tutorial/body/ + why: The new slice's request-body endpoints follow this. + +- url: https://fastapi.tiangolo.com/tutorial/bigger-applications/#apirouter + why: Confirms APIRouter(prefix=...) + include_router registration. + +- url: https://docs.pydantic.dev/latest/concepts/strict_mode/ + why: Request bodies use ConfigDict(strict=True) + per-field Field(strict=False) + on JSON-non-native types (date); response models do NOT. This is the repo's + docs/_base/SECURITY.md policy — get it right or every HTTP caller 422s on + date fields. + +- url: https://docs.pydantic.dev/latest/concepts/models/ + why: model_dump(mode="json") for JSONB persistence of date/datetime fields. + +- url: https://recharts.org/en-US/api/LineChart + why: The baseline-vs-scenario two-series chart; TimeSeriesChart already wraps + Recharts — pass two series, do not hand-roll a chart. + +- url: https://tanstack.com/query/latest/docs/framework/react/guides/mutations + why: useSimulateScenario/useCreateScenario/useDeleteScenario are mutations; + useScenarios/useScenario are queries. Mirror use-jobs.ts. + +- url: https://www.nist.gov/itl/ai-risk-management-framework + why: The "Risks" section of the brief — over-trust of heuristic numbers. Drives + the MANDATORY method: "heuristic" label + a disclaimer string on every + ScenarioComparison (a transparency / explainability control). +``` + +> Note: LightGBM / XGBoost / Prophet / scikit-learn `TimeSeriesSplit` (cited in +> the feature brief) are **context for the Full Version only**. The MVP does NOT +> add an exogenous-regressor model — do not pull these in for MVP implementation. + +### Current Codebase tree (relevant slices) + +```bash +app/ +├── main.py # router wiring — add scenarios_router +├── core/ +│ ├── config.py # get_settings() — forecast_model_artifacts_dir (str) +│ ├── database.py # get_db dependency +│ └── problem_details.py # RFC 7807 envelope +└── features/ + ├── data_platform/models.py # SalesDaily, PriceHistory, Promotion, Calendar + ├── forecasting/ + │ ├── persistence.py # load_model_bundle <- IMPORT THIS + │ ├── service.py # ForecastingService.predict <- replicate body + │ ├── models.py # baseline forecasters (ignore exogenous X) + │ └── schemas.py # ForecastPoint, PredictResponse, TrainRequest + ├── jobs/ # Job model, _execute_predict (artifact resolution) + ├── registry/models.py # ModelRun + └── ops/ # the pattern-mirror slice (pure helpers, schemas) +alembic/versions/ +└── 378c112e4b32_create_app_config_table.py # <- current head (VERIFY) +frontend/src/ +├── pages/visualize/{demand,forecast,backtest}.tsx +├── hooks/{use-jobs,use-ops}.ts +├── lib/{demand-utils,csv-export,constants}.ts +├── components/charts/time-series-chart.tsx +├── components/common/{job-picker,error-display,loading-state,status-badge}.tsx +├── types/api.ts +└── App.tsx +``` + +### Desired Codebase tree — files to ADD + +```bash +# ── Backend: the new `scenarios` vertical slice ── +app/features/scenarios/__init__.py # slice package + __all__ +app/features/scenarios/models.py # ScenarioPlan ORM model (JSONB) +app/features/scenarios/schemas.py # request + response Pydantic models +app/features/scenarios/adjustments.py # PURE deterministic factor math +app/features/scenarios/service.py # ScenarioService (simulate + CRUD) +app/features/scenarios/routes.py # APIRouter — 5 endpoints +app/features/scenarios/tests/__init__.py +app/features/scenarios/tests/conftest.py # real-Postgres fixtures + cleanup +app/features/scenarios/tests/test_adjustments.py # PURE-function unit tests +app/features/scenarios/tests/test_schemas.py # schema unit tests +app/features/scenarios/tests/test_leakage.py # leakage spec (load-bearing) +app/features/scenarios/tests/test_routes_integration.py # @pytest.mark.integration + +# ── Backend: migration ── +alembic/versions/_create_scenario_plan_table.py # new table + +# ── Frontend ── +frontend/src/hooks/use-scenarios.ts # query + mutation hooks +frontend/src/lib/scenario-utils.ts # PURE chart-merge + delta utils +frontend/src/lib/scenario-utils.test.ts # vitest +frontend/src/pages/visualize/planner.tsx # the /visualize/planner What-If page +``` + +### Files to MODIFY (all additive) + +```bash +app/main.py # +1 import, +1 include_router(scenarios_router) +frontend/src/types/api.ts # +Scenario* interfaces +frontend/src/lib/constants.ts # +ROUTES.VISUALIZE.PLANNER + nav entry +frontend/src/App.tsx # +1 lazy import + 1 +README.md # feature-list mention +docs/_base/API_CONTRACTS.md # +/scenarios/* rows +docs/_base/REPO_MAP_INDEX.md # +scenarios slice + planner.tsx rows +docs/_base/DOMAIN_MODEL.md # +scenario_plan aggregate + ubiquitous-language rows +``` + +### Known Gotchas of our codebase & Library Quirks + +| # | Gotcha | Mitigation | +|---|--------|-----------| +| 1 | A predict/train job's `run_id` is the **artifact key** (`model_{run_id}.joblib`), NOT a registry `model_run.run_id`. Passing the wrong one yields a missing artifact. | Resolve the artifact exactly as `jobs/service.py:_execute_predict` does. The page picks a *completed predict job* whose `result.run_id` is the artifact key. A bogus `run_id` must surface as a 404/400 problem, never a 500. | +| 2 | A slice may **NOT** import another slice's `service.py`. Importing `ForecastingService` from `scenarios` violates `AGENTS.md` § Architecture. | Import only `load_model_bundle` from `forecasting/persistence.py`; produce the baseline by calling `bundle.model.predict(horizon)` and replicating the ~30-line `ForecastPoint`-construction block. Cite in the PR (DECISIONS LOCKED #2). | +| 3 | FastAPI calls `TypeAdapter.validate_python` on request bodies. With `ConfigDict(strict=True)`, Pydantic refuses to coerce ISO-string dates → every HTTP caller 422s on `date` fields. | Request bodies: `ConfigDict(strict=True)` + `Field(strict=False, ...)` on **every** `date`/`datetime` field. Response models: `from_attributes=True`, **NO** `strict=True`. (`docs/_base/SECURITY.md`.) | +| 4 | JSONB rejects Python `date`/`datetime` objects. | Persist `assumptions` / `comparison` via `model_dump(mode="json")` so dates serialise to ISO strings. | +| 5 | SQLAlchemy reserves the attribute name `metadata` on declarative models (`rag` works around it with `metadata_`/`"metadata"`). | Name the `scenario_plan` JSONB columns `assumptions` and `comparison` — never `metadata`. | +| 6 | The current Alembic head is **`378c112e4b32`**, not the `d6e0f2g3h456` the source plan guessed. | Set `down_revision = "378c112e4b32"`, but re-verify with `uv run alembic heads` immediately before writing the migration. Migrations are forward-only after merge. | +| 7 | There is **no `scenarios` commit scope** in the allow-list. | Use `feat(api)` / `feat(api,db)` / `feat(ui)` / `test(api)` / `docs(docs)` (DECISIONS LOCKED #4). | +| 8 | The baseline forecasters ignore exogenous regressors — a "what-if" cannot be done by re-prediction. | The MVP applies a **post-forecast deterministic multiplier**; label every result `method = "heuristic"` + a `disclaimer`. | +| 9 | A green `pnpm tsc` is NOT proof the UI works. | Dogfood the running page in a real browser via `webapp-testing` / `agent-browser` (Task C7) — mandatory per `.claude/rules/ui-design.md`. | +| 10 | `units_delta_pct` divide-by-zero when baseline demand is 0. | Guard: return `0.0` when `baseline_total_units == 0`. | +| 11 | An assumption window can fall entirely **before** the forecast start. | The adjustment touches **only** horizon (future) days; out-of-window days contribute factor `1.0`. The leakage test asserts this. | +| 12 | The repo uses **CRLF line endings** on `.py` files (no `.gitattributes`); scripted text-mode writes can silently flip them to LF. | Edit `app/main.py` minimally; preserve existing line endings. | + +--- + +## Implementation Blueprint + +### Data models and structure + +**Backend — `adjustments.py` (PURE, no DB, no I/O, never raises):** + +```python +# Module constants — documented deterministic heuristic factors. +# Final values are a planning DECISION — lock them before coding (see NOTES). +PRICE_ELASTICITY = -1.2 # demand_factor = (1 + change_pct) ** PRICE_ELASTICITY +PROMOTION_UPLIFT_BY_KIND = {"pct_off": 1.25, "bogo": 1.40, "bundle": 1.15, "markdown": 1.30} +HOLIDAY_UPLIFT = 1.30 +LIFECYCLE_FACTOR = {"launch": 1.2, "growth": 1.1, "maturity": 1.0, "decline": 0.85} +FACTOR_BAND = (0.1, 5.0) # clamp band — no negative / explosive forecast + +def clamp(value, lo, hi) -> float: ... +def price_factor(price_change_pct: float) -> float: ... # constant-elasticity +def promotion_factor(kind: str, active: bool) -> float: ... # 1.0 if not active / unknown kind +def holiday_factor(is_holiday: bool) -> float: ... +def lifecycle_factor(stage: str | None) -> float: ... # 1.0 for None / unknown +def combined_daily_factor(*, day_index, horizon, assumptions) -> float: ... # multiply applicable, clamp +def apply_adjustment(baseline: list[float], factors: list[float]) -> list[float]: + # element-wise multiply; len asserted equal; every output max(0.0, ...) +``` + +**Backend — `schemas.py` (Pydantic v2):** + +```python +# Request models — ConfigDict(strict=True) + Field(strict=False) on every date: +class PriceAssumption: change_pct: float (ge=-0.9, le=5.0); start_date/end_date: date +class PromotionAssumption: kind: Literal["pct_off","bogo","bundle","markdown"]; start_date/end_date: date +class HolidayAssumption: dates: list[date] +class InventoryAssumption: on_hand_units: int (ge=0) # caps coverage, not demand +class LifecycleAssumption: stage: Literal["launch","growth","maturity","decline"] +class ScenarioAssumptions: price/promotion/holiday/inventory/lifecycle: ... | None = None +class SimulateScenarioRequest: run_id: str; horizon: int (ge=1, le=90); assumptions; name: str | None +class CreateScenarioRequest: name: str; run_id: str; horizon: int; assumptions: ScenarioAssumptions + +# Response models — ConfigDict(from_attributes=True), NO strict=True, Field(..., description=): +class ScenarioPoint: date; baseline; scenario; delta; applied_factor: float +class ScenarioComparison: store_id; product_id; model_type; horizon; points: list[ScenarioPoint]; + baseline_total_units; scenario_total_units; units_delta; units_delta_pct; + baseline_revenue; scenario_revenue; revenue_delta; unit_price_used; + coverage_verdict: Literal["covered","at_risk","stockout","unknown"]; + method: Literal["heuristic"]; disclaimer: str; generated_at: datetime +class ScenarioPlanResponse: scenario_id; name; store_id; product_id; run_id; horizon; method; + created_at; comparison: ScenarioComparison; assumptions: ScenarioAssumptions +class ScenarioListItem: scenario_id; name; store_id; product_id; units_delta; revenue_delta; created_at +class ScenarioListResponse: scenarios: list[ScenarioListItem]; total: int (ge=0) +``` + +**Backend — `models.py` (`ScenarioPlan(TimestampMixin, Base)`):** + +```python +id: int (PK); scenario_id: str String(32) unique index; name: str String(200) +store_id: int (index); product_id: int (index); run_id: str String(32) (index) +horizon: int +assumptions: dict[str, Any] -> JSONB # raw ScenarioAssumptions dump +comparison: dict[str, Any] -> JSONB # full ScenarioComparison snapshot +method: str String(20) -> CheckConstraint("method IN ('heuristic')") +__table_args__: GIN index on assumptions + comparison; composite (store_id, product_id) +``` + +### list of tasks to be completed (dependency-ordered) + +The work is **three independently-shippable phases**. Prefer one PR per phase (or +one phased PR). **Phase A must merge before Phase C can be dogfooded** (the page +needs the endpoint). + +```yaml +Task 0 — SETUP: tracking issue + branch: + - Open ONE GitHub issue "Scenario Simulation / What-If Planning (MVP)"; confirm OPEN. + - git fetch origin && git switch -c feat/scenario-what-if-planner origin/dev + - GOTCHA: no `scenarios` commit scope — use feat(api)/feat(api,db)/feat(ui)/docs(docs). + - VALIDATE: gh issue view --json state -> OPEN + +# ════════ PHASE A — Stateless Simulation Engine (backend) ════════ + +Task A1 — CREATE app/features/scenarios/__init__.py + tests/__init__.py: + - Docstring + empty __all__ (extend as schemas land); empty tests/__init__.py. + - PATTERN: app/features/ops/__init__.py + - VALIDATE: uv run python -c "import app.features.scenarios" + +Task A2 — CREATE app/features/scenarios/adjustments.py: + - The PURE deterministic adjustment engine (see Data models above). stdlib only, + `from __future__ import annotations`, no numpy. Every helper tolerates junk + input (negative pct, unknown kind, None stage) and returns a sane factor — + NEVER raises. + - PATTERN: app/features/ops/service.py pure module-scope helpers. + - VALIDATE: uv run python -c "from app.features.scenarios.adjustments import combined_daily_factor; print('ok')" + +Task A3 — CREATE app/features/scenarios/schemas.py (Phase A subset): + - The simulate request models + ScenarioComparison + ScenarioPoint (see above). + - PATTERN: forecasting/schemas.py:TrainRequest (request, strict); ops/schemas.py + (response, from_attributes). + - GOTCHA: strict=True ONLY on request bodies; Field(strict=False) on every date. + - VALIDATE: uv run python -c "from app.features.scenarios.schemas import SimulateScenarioRequest, ScenarioComparison; print('ok')" + +Task A4 — CREATE app/features/scenarios/service.py (Phase A subset): + - ScenarioService.simulate(db, request) -> ScenarioComparison: + 1. Resolve artifact: artifacts_dir = Path(settings.forecast_model_artifacts_dir); + model_path = (artifacts_dir / f"model_{run_id}.joblib").resolve() + (mirror jobs/service.py:_execute_predict — the setting is a str, wrap in Path). + Then mirror the LOAD-BEARING path-traversal guard from + forecasting/service.py:218-248 — reject a non-`.joblib` suffix and any path + that escapes artifacts_dir (`resolved_path.relative_to(artifacts_dir)`) with + ValueError. FileNotFoundError if the validated path is absent. + 2. load_model_bundle -> read store_id/product_id from bundle.metadata. + 3. Produce the baseline series by calling bundle.model.predict(horizon) and + replicating the ForecastPoint-construction block from + ForecastingService.predict (DECISIONS LOCKED #2 — do NOT import the + sibling service). + 4. Estimate unit_price_used: most-recent non-null SalesDaily.unit_price for + (store, product); fall back to a documented default + log a warning. + 5. Per horizon day: applied_factor = adjustments.combined_daily_factor(...); + scenario = max(0.0, baseline * applied_factor). + 6. Aggregate totals, units_delta, units_delta_pct (guard /0), revenue, deltas. + 7. coverage_verdict from the inventory assumption (covered / at_risk / stockout + / unknown). + 8. Return ScenarioComparison(method="heuristic", disclaimer=). + logger.info("scenarios.simulated", ...). + - PATTERN: ops/service.py (class shape, logging); jobs/service.py:_execute_predict + (artifact resolution). + - VALIDATE: uv run mypy app/features/scenarios/ && uv run pyright app/features/scenarios/ + +Task A5 — CREATE app/features/scenarios/routes.py (Phase A subset): + - router = APIRouter(prefix="/scenarios", tags=["scenarios"]); + POST /scenarios/simulate -> response_model=ScenarioComparison, rich docstring. + Map FileNotFoundError/ValueError -> RFC 7807 problem (read forecasting/routes.py + + app/core/problem_details.py first). + - PATTERN: ops/routes.py; forecasting/routes.py. + - VALIDATE: uv run ruff check app/features/scenarios/ && uv run mypy app/ + +Task A6 — UPDATE app/main.py: + - +1 import (alphabetical) + app.include_router(scenarios_router). + - GOTCHA: preserve line endings; edit minimally (Gotcha #12). + - VALIDATE: uv run python -c "from app.main import app; assert '/scenarios/simulate' in {r.path for r in app.routes}; print('wired')" + +Task A7 — CREATE tests/test_adjustments.py + test_schemas.py: + - test_adjustments.py: every pure helper — factor math, clamp bounds, + kind/stage fallthrough, junk-input tolerance, apply_adjustment element-wise + + non-negative. + - test_schemas.py: SimulateScenarioRequest from ISO-string dates via + model_validate({...}) (the FastAPI validate_python path); change_pct bounds. + - PATTERN: ops/tests/test_service.py + test_schemas.py. + - VALIDATE: uv run pytest -v -m "not integration" app/features/scenarios/tests/test_adjustments.py app/features/scenarios/tests/test_schemas.py + +Task A8 — CREATE tests/test_leakage.py (LOAD-BEARING): + - Assert the adjustment touches ONLY horizon points: apply_adjustment returns a + new list and leaves the input baseline unchanged; out-of-window days + contribute factor 1.0; len(points) == horizon; an assumption window before + the forecast start contributes no factor. + - PATTERN: app/features/featuresets/tests/test_leakage.py. + - GOTCHA: never weaken this test to make a feature pass (AGENTS.md § Safety). + - VALIDATE: uv run pytest -v -m "not integration" app/features/scenarios/tests/test_leakage.py + +# ════════ PHASE B — Saved Scenario Plans (persistence) ════════ + +Task B1 — CREATE app/features/scenarios/models.py: + - ScenarioPlan(TimestampMixin, Base) — see Data models above. + - PATTERN: jobs/models.py + rag/models.py:DocumentSource. + - GOTCHA: do NOT name a column `metadata` (Gotcha #5). + - VALIDATE: uv run python -c "from app.features.scenarios.models import ScenarioPlan; print(ScenarioPlan.__tablename__)" + +Task B2 — CREATE the Alembic migration: + - uv run alembic revision -m "create scenario plan table", then hand-write + upgrade() (op.create_table with all columns, postgresql.JSONB(astext_type= + sa.Text()), GIN indexes, the CheckConstraint, created_at/updated_at with + server_default=sa.text('now()')) and a real downgrade(). + - PATTERN: alembic/versions/37e16ecef223_create_jobs_table.py. + - GOTCHA: confirm head with `uv run alembic heads` and set down_revision to it + (currently 378c112e4b32 — VERIFY, do not assume; Gotcha #6). + - VALIDATE: docker compose up -d && uv run alembic upgrade head && uv run alembic downgrade -1 && uv run alembic upgrade head + +Task B3 — EXTEND app/features/scenarios/schemas.py: + - Add CreateScenarioRequest (request), ScenarioPlanResponse, ScenarioListItem, + ScenarioListResponse (responses — from_attributes). + - PATTERN: ops/schemas.py; jobs/schemas.py:JobListResponse. + - VALIDATE: uv run mypy app/features/scenarios/schemas.py + +Task B4 — EXTEND app/features/scenarios/service.py: + - create_plan (runs simulate, persists ScenarioPlan, scenario_id=uuid4().hex), + list_plans, get_plan, delete_plan. + - GOTCHA: persist comparison + assumptions via model_dump(mode="json") + (Gotcha #4). + - PATTERN: jobs/service.py (create/list/get); registry/service.py. + - VALIDATE: uv run mypy app/ && uv run pyright app/ + +Task B5 — EXTEND app/features/scenarios/routes.py: + - POST /scenarios (201); GET /scenarios (limit/offset Query params, bounded); + GET /scenarios/{scenario_id} (404 problem when missing); + DELETE /scenarios/{scenario_id} (204, 404 when missing). + - PATTERN: registry/routes.py (alias CRUD with 404 mapping). + - VALIDATE: uv run python -c "from app.main import app; paths={r.path for r in app.routes}; assert {'/scenarios','/scenarios/{scenario_id}'} <= paths; print('wired')" + +Task B6 — CREATE tests/conftest.py + test_routes_integration.py: + - conftest.py: real-Postgres fixtures (ASGITransport client; scoped cleanup that + INCLUDES delete(ScenarioPlan) + FK-safe deletes of seeded TEST-/test- rows; a + trained_model fixture that puts a real bundle on disk for simulate). + - test_routes_integration.py (@pytest.mark.integration): simulate happy path + (200, points length == horizon, method == "heuristic"); bogus run_id -> RFC + 7807 problem (not 500); full CRUD round-trip; GET /scenarios on empty table -> + 200 + []. Plus a constraint test for the method CheckConstraint. + - PATTERN: ops/tests/conftest.py + test_routes_integration.py; + forecasting/tests/conftest.py. + - GOTCHA: never mock the DB; integration tests need docker compose up + alembic + upgrade head. + - VALIDATE: docker compose up -d && uv run alembic upgrade head && uv run pytest -v -m integration app/features/scenarios/ + +# ════════ PHASE C — What-If Planner Page (frontend) ════════ + +Task C1 — UPDATE frontend/src/types/api.ts: + - Add Scenario* TS interfaces (dates as string) mirroring the backend schemas. + - PATTERN: the Ops* / InventoryStatus* blocks already in types/api.ts. + - VALIDATE: cd frontend && pnpm tsc --noEmit + +Task C2 — CREATE frontend/src/hooks/use-scenarios.ts: + - useSimulateScenario (mutation), useCreateScenario (mutation, invalidates list), + useScenarios (query), useScenario(scenarioId) (query), useDeleteScenario + (mutation). + - PATTERN: use-jobs.ts (useCreateJob mutation + useJobs query); use-ops.ts. + - VALIDATE: cd frontend && pnpm tsc --noEmit + +Task C3 — CREATE frontend/src/lib/scenario-utils.ts + scenario-utils.test.ts: + - PURE utils: mergeComparisonSeries (ScenarioPoint[] -> chart rows), + formatDelta (signed), deltaCsvColumns (CsvColumn[]), + summariseAssumptions (human-readable bullets). + - PATTERN: demand-utils.ts. + - VALIDATE: cd frontend && pnpm test --run src/lib/scenario-utils.test.ts + +Task C4 — UPDATE frontend/src/lib/constants.ts + frontend/src/App.tsx: + - ROUTES.VISUALIZE.PLANNER = '/visualize/planner'; a Visualize NAV_ITEMS entry; + a lazy import + in App.tsx (copy the DEMAND route). + - GOTCHA: pnpm tsc fails until Task C5 creates the page — re-run after C5. + - VALIDATE: (after C5) cd frontend && pnpm tsc --noEmit + +Task C5 — CREATE frontend/src/pages/visualize/planner.tsx: + - Build via frontend-design + shadcn-ui skills. Header Card with a prominent + heuristic-disclaimer banner; baseline picker (JobPicker jobType="predict" + + horizon Select); assumptions form (price slider, promotion kind + window, + holiday dates, inventory units, lifecycle stage — all optional); a "Run + simulation" Button; results (TimeSeriesChart two-series, KPI tiles, per-day + delta Table + Export CSV, "Save as plan"); a saved-plans Card (list, reload, + delete). Standard LoadingState / ErrorDisplay / EmptyState early returns. + - PATTERN: demand.tsx (skeleton, states, drill-in, CSV); forecast.tsx (in-page + job launch). + - GOTCHA: renders inside AppShell; shadcn semantic tokens only; a green tsc is + NOT proof the UI works (Gotcha #9). + - VALIDATE: cd frontend && pnpm tsc --noEmit && pnpm lint + +Task C6 — UPDATE docs: + - README.md (feature list); docs/_base/API_CONTRACTS.md (5 /scenarios/* rows); + docs/_base/REPO_MAP_INDEX.md (scenarios slice + planner.tsx); + docs/_base/DOMAIN_MODEL.md (scenario_plan aggregate + ubiquitous-language rows). + - VALIDATE: git diff --stat docs/ README.md + +Task C7 — Dogfood the running UI (MANDATORY per .claude/rules/ui-design.md): + - docker compose up -d && alembic upgrade head && seed_random --full-new, then + `make demo` (so completed predict jobs + trained models exist), start uvicorn + + vite, exercise via webapp-testing / agent-browser: pick a baseline job, + define a -15% price + a pct_off promotion, run, confirm the two-series chart + + non-zero deltas + the disclaimer banner, export the delta CSV, save the plan, + reload it, delete it. Capture screenshots. + - VALIDATE: screenshots captured; all 8 manual-check scenarios pass. + +Task C8 — Commit + PR: + - Commits (each (#issue), no AI co-author trailer): + feat(api): add scenario simulation engine and simulate endpoint (#N) + test(api): cover scenario adjustments, schemas, and leakage spec (#N) + feat(api,db): add scenario_plan table and CRUD endpoints (#N) + test(api): cover scenario plan persistence and CRUD (#N) + feat(ui): add scenario data layer — types, hooks, scenario-utils (#N) + feat(ui): add Visualize What-If Planner page (#N) + docs(docs): document scenario simulation slice and planner page (#N) + - GOTCHA: the PR description MUST flag (a) results are heuristic, deliberately + labelled, not model-causal — the Full-Version exogenous-model path is out of + scope; (b) the scenario service deliberately does NOT import sibling + ForecastingService (DECISIONS LOCKED #2). Answer the 6 product-vision Litmus + questions. + - VALIDATE: open PR into dev; CI green; merge. +``` + +### Per-task pseudocode (critical details only) + +```python +# Task A4 — ScenarioService.simulate (the heart of Phase A) +async def simulate(self, db: AsyncSession, request: SimulateScenarioRequest) -> ScenarioComparison: + settings = get_settings() + # GOTCHA #1/#2: resolve the ARTIFACT, mirror jobs/service.py:_execute_predict. + # forecast_model_artifacts_dir is a str — wrap in Path (NOT settings.artifacts_dir). + artifacts_dir = Path(settings.forecast_model_artifacts_dir).resolve() + model_path = (artifacts_dir / f"model_{request.run_id}.joblib").resolve() + # LOAD-BEARING path-traversal guard — mirror forecasting/service.py:218-248 + if model_path.suffix != ".joblib": + raise ValueError(f"Invalid model path for run_id={request.run_id}") + try: + model_path.relative_to(artifacts_dir) # rejects ../ escape + except ValueError: + raise ValueError(f"Invalid model path for run_id={request.run_id}") from None + if not model_path.exists(): + raise FileNotFoundError(f"No model artifact for run_id={request.run_id}") + bundle = load_model_bundle(model_path) # forecasting/persistence.py + # bundle.metadata is dict[str, object] — int(str(...)) keeps mypy --strict happy + store_id = int(str(bundle.metadata["store_id"])) + product_id = int(str(bundle.metadata["product_id"])) + # DECISIONS LOCKED #2: replicate the ForecastingService.predict body — do NOT import it + raw = bundle.model.predict(request.horizon) # BaseForecaster interface + # train_end_date is stored as an ISO STRING — parse it; fall back to today if absent + train_end_raw = bundle.metadata.get("train_end_date") + train_end_date = (date.fromisoformat(train_end_raw) + if isinstance(train_end_raw, str) + else datetime.now(UTC).date()) + start = train_end_date + timedelta(days=1) + baseline_pts = [ForecastPoint(date=start + timedelta(days=i), forecast=float(v)) + for i, v in enumerate(raw)] + # estimate unit price (revenue delta) + unit_price = await self._latest_unit_price(db, store_id, product_id) # default + warn if none + # apply per-day deterministic factors — adjustments.py is PURE + factors = [adjustments.combined_daily_factor(day_index=i, horizon=request.horizon, + assumptions=request.assumptions) for i in range(request.horizon)] + baseline = [p.forecast for p in baseline_pts] + scenario = adjustments.apply_adjustment(baseline, factors) # element-wise, max(0.0,...) + # aggregate — guard divide-by-zero (Gotcha #10) + ... + return ScenarioComparison(method="heuristic", disclaimer=HEURISTIC_DISCLAIMER, ...) +``` + +### Integration Points + +```yaml +DATABASE: + - migration: "create scenario_plan table (id, scenario_id, name, store_id, + product_id, run_id, horizon, assumptions JSONB, comparison JSONB, + method, created_at, updated_at)" + - index: "GIN on assumptions + comparison; composite (store_id, product_id); + unique on scenario_id" + - constraint: "CheckConstraint method IN ('heuristic')" + - down_revision: "378c112e4b32 (VERIFY with `uv run alembic heads`)" + +ROUTES: + - add to: app/main.py + - pattern: "from app.features.scenarios.routes import router as scenarios_router + ... app.include_router(scenarios_router)" + +FRONTEND ROUTING: + - add to: frontend/src/lib/constants.ts + - pattern: "ROUTES.VISUALIZE.PLANNER = '/visualize/planner' + a Visualize + NAV_ITEMS entry" + - add to: frontend/src/App.tsx + - pattern: "lazy(() => import('@/pages/visualize/planner')) + a " + +CONFIG: + - no new config — reuses settings.forecast_model_artifacts_dir (a str; + wrap with Path(...) before use, app/core/config.py) +``` + +--- + +## Validation Loop + +### Level 1: Syntax & Style + +```bash +uv run ruff check . --fix && uv run ruff format --check . +cd frontend && pnpm lint +# Traps: date.today() / naive datetime -> ruff DTZ (use datetime.now(UTC)); +# os.path -> ruff PTH (use pathlib.Path); a stray # noqa -> RUF100. +``` + +### Level 2: Type Checks + +```bash +uv run mypy app/ && uv run pyright app/ # both --strict, both gate merge +cd frontend && pnpm tsc --noEmit +``` + +### Level 3: Unit Tests + +```bash +uv run pytest -v -m "not integration" app/features/scenarios/ +cd frontend && pnpm test --run src/lib/scenario-utils.test.ts +``` + +### Level 4: Integration Tests + +```bash +docker compose up -d && uv run alembic upgrade head +uv run pytest -v -m integration app/features/scenarios/ +# Migration up/down check: +uv run alembic downgrade -1 && uv run alembic upgrade head +``` + +### Level 5: Manual Validation (dogfood — REQUIRED) + +```bash +docker compose up -d && uv run alembic upgrade head +uv run python scripts/seed_random.py --full-new --seed 42 --confirm +make demo # populates predict jobs + models +uv run uvicorn app.main:app --port 8123 & +until curl -fs http://127.0.0.1:8123/health; do sleep 2; done +# stateless simulate: +curl -s -X POST http://localhost:8123/scenarios/simulate \ + -H 'content-type: application/json' \ + -d '{"run_id":"","horizon":14, + "assumptions":{"price":{"change_pct":-0.15, + "start_date":"2026-06-01","end_date":"2026-06-14"}}}' | head -c 600 +curl -s -o /dev/null -w '%{http_code}\n' -X POST \ + http://localhost:8123/scenarios/simulate -H 'content-type: application/json' \ + -d '{"run_id":"does-not-exist","horizon":14,"assumptions":{}}' # expect 404, not 500 +# Frontend: cd frontend && ./node_modules/.bin/vite --host 0.0.0.0 +# -> open http://localhost:5173/visualize/planner via webapp-testing/agent-browser: +# pick a baseline job, set a price + promotion assumption, run, verify the +# two-series chart + non-zero deltas + the heuristic disclaimer, export the +# delta CSV, save the plan, reload it from the saved list, delete it. +``` + +### Level 6: Additional Validation (optional) + +```bash +# Confirm Recharts / TanStack Query usage against current docs via the contex7 MCP +# if the TimeSeriesChart two-series wiring or a mutation pattern is uncertain. +``` + +--- + +## Final Validation Checklist + +- [ ] `uv run ruff check . && uv run ruff format --check .` — clean +- [ ] `uv run mypy app/ && uv run pyright app/` — clean (`--strict`) +- [ ] `uv run pytest -v -m "not integration"` — green (incl. the leakage spec) +- [ ] `docker compose up -d && uv run pytest -v -m integration` — green +- [ ] `uv run alembic upgrade head && uv run alembic downgrade -1 && uv run alembic upgrade head` — clean +- [ ] `cd frontend && pnpm tsc --noEmit && pnpm lint && pnpm test --run` — green +- [ ] `POST /scenarios/simulate` behaves per Success Criteria (points length == + horizon, method == "heuristic", bogus run_id -> RFC 7807, not 500) +- [ ] CRUD round-trip works; `GET /scenarios` on an empty table -> 200 + [] +- [ ] `/visualize/planner` runs a simulation, shows a two-series chart + delta + table + heuristic disclaimer, exports CSV, saves/reloads/deletes a plan — + dogfooded in a browser (screenshots captured) +- [ ] No new external dependency; no managed-cloud SDK; no WebSocket +- [ ] `scenario_plan` table created via migration; columns named `assumptions` / + `comparison` (never `metadata`) +- [ ] README + `docs/_base/{API_CONTRACTS,REPO_MAP_INDEX,DOMAIN_MODEL}.md` updated +- [ ] Branch `feat/scenario-what-if-planner`; every commit references the tracking + issue; commit scopes are `api`/`api,db`/`ui`/`docs` (no `scenarios` scope); + no AI co-author trailer +- [ ] PR description flags: (a) heuristic, not model-causal — Full Version out of + scope; (b) the scenario service deliberately does NOT import sibling + `ForecastingService` (DECISIONS LOCKED #2); and answers the 6 Litmus-Test + questions + +--- + +## Anti-Patterns to Avoid + +- ❌ Don't import `ForecastingService` (or any sibling slice's `service.py`) into + `scenarios` — import only `load_model_bundle` from `forecasting/persistence.py` + and replicate the predict body (DECISIONS LOCKED #2). +- ❌ Don't add an exogenous-regressor model or a future-feature-frame generator — + that is the Full Version, out of scope (DECISIONS LOCKED #1). +- ❌ Don't re-train a model to produce the scenario — the MVP applies a + post-forecast deterministic multiplier. +- ❌ Don't drop the `method: "heuristic"` label or the `disclaimer` — they are the + NIST-AI-RMF transparency control against over-trust. +- ❌ Don't put `ConfigDict(strict=True)` on response models; don't omit + `Field(strict=False)` on `date` fields of request bodies (Gotcha #3). +- ❌ Don't name a `scenario_plan` column `metadata` — SQLAlchemy reserves it + (Gotcha #5). +- ❌ Don't persist Python `date`/`datetime` into JSONB — use + `model_dump(mode="json")` (Gotcha #4). +- ❌ Don't guess the migration `down_revision` — verify with `uv run alembic heads` + (Gotcha #6). +- ❌ Don't weaken `test_leakage.py` to make a feature pass — it is the spec. +- ❌ Don't `raise HTTPException(500, "raw string")` — use the RFC 7807 envelope. +- ❌ Don't add a WebSocket — simulation is request/response (DECISIONS LOCKED #6). +- ❌ Don't `pnpm add` anything — Recharts / TanStack Query / shadcn primitives are + installed. +- ❌ Don't hand-roll a chart — pass two series to the existing `TimeSeriesChart`. +- ❌ Don't claim the UI works on a green type-check — dogfood it in a browser. +- ❌ Don't invent a `scenarios` commit scope — use `api`/`api,db`/`ui`/`docs`. + +## NOTES — open questions / planning decisions to lock before coding + +- **Heuristic factor values are not yet final.** `PRICE_ELASTICITY = -1.2`, + `PROMOTION_UPLIFT_BY_KIND`, `HOLIDAY_UPLIFT = 1.30`, `LIFECYCLE_FACTOR`, and the + `FACTOR_BAND` clamp `[0.1, 5.0]` are *suggested starting values*. They are a + planning decision — confirm them (or adjust) before coding `adjustments.py`. + They are deliberately conservative and documented as constants so a reviewer can + see and tune them. The tests assert *direction and bounds* (a price cut → uplift + > 1, a clamp keeps the factor in band), not exact magnitudes — so reasonable + re-tuning does not break tests. +- **`coverage_verdict` band**: `at_risk` is suggested as "scenario total within + 10% of `on_hand_units`". Confirm the band before coding. +- **`unit_price_used` fallback**: when no `SalesDaily` row exists for the + `(store, product)`, the service falls back to a documented default (suggested + `1.0`) and logs a warning. Confirm the default. +- **Lifecycle stage source**: the MVP takes `lifecycle.stage` as a direct user + override on the assumption form — it does not derive the current stage from + `product.launch_date`. Deriving it is a Full-Version concern. + +## Confidence Score + +**9 / 10** for one-pass implementation success. + +Rationale: the source plan (`.agents/plans/scenario-simulation-what-if-planning.md`) +is unusually thorough and was validated against the repo as of 2026-05-19 — every +file path, class name, and pattern reference here was cross-checked. The two +highest-risk areas are both de-risked: (1) the cross-slice-import constraint is +resolved by a locked decision (import `load_model_bundle`, replicate the predict +body) rather than left for the implementer to discover; (2) the artifact-resolution +gotcha (`run_id` is the artifact key, not a registry id) is called out explicitly +with the exact reference (`jobs/service.py:_execute_predict`). `adjustments.py` is +pure, dependency-free, and trivially unit-testable. Phase C is a near-exact mirror +of PRP-22's `demand.tsx` shape over a deterministic backend. The residual 1-point +risk is the heuristic factor *values* (NOTES) — a planning decision that does not +affect the structure, and the tests assert direction/bounds rather than exact +magnitudes, so re-tuning is safe. The biggest scope risks of the feature brief — +an exogenous-regressor model and a WebSocket — are removed by DECISIONS LOCKED #1 +and #6, keeping every phase aligned with the single-host, non-streaming, time-safe +product vision. diff --git a/README.md b/README.md index 27210c98..6298904e 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Portfolio-grade end-to-end retail demand forecasting system. - **Dashboard**: React 19 + Vite + Tailwind CSS 4 + shadcn/ui for data exploration and model management - **Explorer**: Click-through detail pages for stores, products, model runs, and jobs; run-vs-run comparison and SHA-256 artifact integrity verification; server-side sortable, CSV-exportable tables with column-visibility toggles and URL-shareable filter/sort/page state across every Explorer page; date-scoped KPIs, revenue bar/line charts, and cross-filtering on the Sales page - **Demand Planner**: `/visualize/demand` — every completed forecast rolled into a multi-SKU table (tomorrow / next-week / next-month demand + inventory requirement), with a lead-time selector and a single-SKU drill-in; the Forecast and Backtest pages run jobs in-page, export CSV, toggle a prediction-interval band, and cross-link to runs/jobs +- **What-If Planner**: `/visualize/planner` — take an existing forecast, apply deterministic price / promotion / holiday / inventory / lifecycle assumptions, and see the baseline-vs-scenario demand and revenue impact (clearly labelled heuristic); save, reload, and delete named scenario plans - **RAG Knowledge Base**: Postgres pgvector embeddings + evidence-grounded answers with citations - **Agentic Layer**: PydanticAI agents for autonomous experimentation and evidence-grounded Q&A with human-in-the-loop approval - **Data Seeder (The Forge)**: Reproducible synthetic data generator with realistic time-series patterns, scenario presets, and retail effects diff --git a/alembic/env.py b/alembic/env.py index 6abccc57..4c4db209 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -18,6 +18,7 @@ from app.features.jobs import models as jobs_models # noqa: F401 from app.features.rag import models as rag_models # noqa: F401 from app.features.registry import models as registry_models # noqa: F401 +from app.features.scenarios import models as scenarios_models # noqa: F401 # Alembic Config object config = context.config diff --git a/alembic/versions/43e35957a248_create_scenario_plan_table.py b/alembic/versions/43e35957a248_create_scenario_plan_table.py new file mode 100644 index 00000000..8fdd4a8b --- /dev/null +++ b/alembic/versions/43e35957a248_create_scenario_plan_table.py @@ -0,0 +1,97 @@ +"""create scenario plan table + +Revision ID: 43e35957a248 +Revises: 378c112e4b32 +Create Date: 2026-05-19 07:34:30.545495 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '43e35957a248' +down_revision: Union[str, None] = '378c112e4b32' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Apply migration — create the scenario_plan table.""" + op.create_table( + 'scenario_plan', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('scenario_id', sa.String(length=32), nullable=False), + sa.Column('name', sa.String(length=200), nullable=False), + sa.Column('store_id', sa.Integer(), nullable=False), + sa.Column('product_id', sa.Integer(), nullable=False), + sa.Column('run_id', sa.String(length=32), nullable=False), + sa.Column('horizon', sa.Integer(), nullable=False), + sa.Column('assumptions', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('comparison', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('method', sa.String(length=20), nullable=False), + sa.Column( + 'created_at', + sa.DateTime(timezone=True), + server_default=sa.text('now()'), + nullable=False, + ), + sa.Column( + 'updated_at', + sa.DateTime(timezone=True), + server_default=sa.text('now()'), + nullable=False, + ), + sa.CheckConstraint("method IN ('heuristic')", name='ck_scenario_plan_method'), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index( + op.f('ix_scenario_plan_scenario_id'), 'scenario_plan', ['scenario_id'], unique=True + ) + op.create_index( + op.f('ix_scenario_plan_store_id'), 'scenario_plan', ['store_id'], unique=False + ) + op.create_index( + op.f('ix_scenario_plan_product_id'), 'scenario_plan', ['product_id'], unique=False + ) + op.create_index( + op.f('ix_scenario_plan_run_id'), 'scenario_plan', ['run_id'], unique=False + ) + op.create_index( + 'ix_scenario_plan_assumptions_gin', + 'scenario_plan', + ['assumptions'], + unique=False, + postgresql_using='gin', + ) + op.create_index( + 'ix_scenario_plan_comparison_gin', + 'scenario_plan', + ['comparison'], + unique=False, + postgresql_using='gin', + ) + op.create_index( + 'ix_scenario_plan_store_product', + 'scenario_plan', + ['store_id', 'product_id'], + unique=False, + ) + + +def downgrade() -> None: + """Revert migration — drop the scenario_plan table.""" + op.drop_index('ix_scenario_plan_store_product', table_name='scenario_plan') + op.drop_index( + 'ix_scenario_plan_comparison_gin', table_name='scenario_plan', postgresql_using='gin' + ) + op.drop_index( + 'ix_scenario_plan_assumptions_gin', table_name='scenario_plan', postgresql_using='gin' + ) + op.drop_index(op.f('ix_scenario_plan_run_id'), table_name='scenario_plan') + op.drop_index(op.f('ix_scenario_plan_product_id'), table_name='scenario_plan') + op.drop_index(op.f('ix_scenario_plan_store_id'), table_name='scenario_plan') + op.drop_index(op.f('ix_scenario_plan_scenario_id'), table_name='scenario_plan') + op.drop_table('scenario_plan') diff --git a/app/features/scenarios/__init__.py b/app/features/scenarios/__init__.py new file mode 100644 index 00000000..20f9d538 --- /dev/null +++ b/app/features/scenarios/__init__.py @@ -0,0 +1,31 @@ +"""Scenario Simulation / What-If Planning slice. + +A vertical slice that turns a baseline forecast into a *plan*: it loads an +already-trained baseline model, runs its forecast, applies deterministic, +transparent uplift / drag factors for future assumptions (price change, +promotion, holiday, inventory, lifecycle), and returns a baseline-vs-scenario +comparison. Comparisons can be persisted as named ``scenario_plan`` rows. + +DECISIONS LOCKED (PRP-26): the baseline forecasters ignore exogenous +regressors, so a "what-if" is applied as a deterministic post-forecast +multiplier — never a leakage-prone re-training. Every result is explicitly +labelled ``method = "heuristic"`` with a fixed disclaimer. +""" + +from app.features.scenarios.models import ScenarioPlan +from app.features.scenarios.routes import router +from app.features.scenarios.schemas import ( + ScenarioComparison, + ScenarioListResponse, + ScenarioPlanResponse, +) +from app.features.scenarios.service import ScenarioService + +__all__ = [ + "ScenarioComparison", + "ScenarioListResponse", + "ScenarioPlan", + "ScenarioPlanResponse", + "ScenarioService", + "router", +] diff --git a/app/features/scenarios/adjustments.py b/app/features/scenarios/adjustments.py new file mode 100644 index 00000000..a4152e3f --- /dev/null +++ b/app/features/scenarios/adjustments.py @@ -0,0 +1,169 @@ +"""Pure deterministic adjustment engine for scenario simulation. + +Every function here is a pure factor computation — no DB, no I/O, no mutation +of its inputs, and it NEVER raises on junk input (a negative price change, an +unknown promotion kind, a ``None`` lifecycle stage all return a sane factor). + +DECISIONS LOCKED (PRP-26 #1): the baseline forecasters ignore exogenous +regressors, so a what-if cannot be answered by re-prediction. The MVP applies +these factors as a post-forecast multiplier on a baseline forecast. Each factor +is a documented, tunable constant so a reviewer can see and adjust the +heuristic; the tests assert direction and bounds, not exact magnitudes. +""" + +from __future__ import annotations + +from datetime import date +from typing import TYPE_CHECKING, Literal + +if TYPE_CHECKING: + from app.features.scenarios.schemas import ScenarioAssumptions + +# Constant-elasticity price response: factor = (1 + change_pct) ** PRICE_ELASTICITY. +# A negative elasticity means a price cut (change_pct < 0) lifts demand. +PRICE_ELASTICITY: float = -1.2 + +# Multiplicative demand uplift per promotion kind (1.0 == no effect). +PROMOTION_UPLIFT_BY_KIND: dict[str, float] = { + "pct_off": 1.25, + "bogo": 1.40, + "bundle": 1.15, + "markdown": 1.30, +} + +# Demand uplift applied on a holiday / event day. +HOLIDAY_UPLIFT: float = 1.30 + +# Demand multiplier per forced product lifecycle stage. +LIFECYCLE_FACTOR: dict[str, float] = { + "launch": 1.2, + "growth": 1.1, + "maturity": 1.0, + "decline": 0.85, +} + +# Clamp band — keeps a combined factor away from a zero / explosive forecast. +FACTOR_BAND: tuple[float, float] = (0.1, 5.0) + +# Relative band around on-hand stock within which coverage is "at_risk". +COVERAGE_AT_RISK_BAND: float = 0.10 + +CoverageVerdict = Literal["covered", "at_risk", "stockout", "unknown"] + + +def clamp(value: float, lo: float, hi: float) -> float: + """Clamp ``value`` into the inclusive ``[lo, hi]`` range.""" + return max(lo, min(hi, value)) + + +def price_factor(price_change_pct: float) -> float: + """Return the demand multiplier for a relative price change. + + Constant-elasticity response: ``(1 + change) ** PRICE_ELASTICITY``. A price + cut (negative change) yields a factor > 1; a price rise yields < 1. + Tolerates junk — a change of -100% or worse (a non-positive price) clamps to + the upper band rather than raising or returning a complex / NaN value. + """ + base = 1.0 + price_change_pct + if base <= 0.0: + return FACTOR_BAND[1] + return clamp(base**PRICE_ELASTICITY, *FACTOR_BAND) + + +def promotion_factor(kind: str, active: bool) -> float: + """Return the demand multiplier for a promotion of ``kind``. + + Returns ``1.0`` when the promotion is not active or the kind is unknown. + """ + if not active: + return 1.0 + return PROMOTION_UPLIFT_BY_KIND.get(kind, 1.0) + + +def holiday_factor(is_holiday: bool) -> float: + """Return the demand multiplier for a holiday / event day.""" + return HOLIDAY_UPLIFT if is_holiday else 1.0 + + +def lifecycle_factor(stage: str | None) -> float: + """Return the demand multiplier for a product lifecycle stage. + + Returns ``1.0`` for ``None`` or an unknown stage. + """ + if stage is None: + return 1.0 + return LIFECYCLE_FACTOR.get(stage, 1.0) + + +def _in_window(point_date: date, start: date, end: date) -> bool: + """True when ``point_date`` is inside the inclusive ``[start, end]`` window. + + A reversed window (``start`` after ``end``) is normalised rather than + treated as empty — junk input must not raise. + """ + lo, hi = (start, end) if start <= end else (end, start) + return lo <= point_date <= hi + + +def combined_daily_factor(point_date: date, assumptions: ScenarioAssumptions) -> float: + """Multiply every applicable per-day factor for ``point_date``, then clamp. + + Time-safety: every window test is keyed on ``point_date`` — always a horizon + (future) date — so an assumption window that falls entirely before the + forecast start contributes factor ``1.0`` and can never reach back into the + historical series. An empty ``ScenarioAssumptions`` yields exactly ``1.0``. + """ + factor = 1.0 + + price = assumptions.price + if price is not None and _in_window(point_date, price.start_date, price.end_date): + factor *= price_factor(price.change_pct) + + promotion = assumptions.promotion + if promotion is not None and _in_window(point_date, promotion.start_date, promotion.end_date): + factor *= promotion_factor(promotion.kind, active=True) + + holiday = assumptions.holiday + if holiday is not None and point_date in holiday.dates: + factor *= holiday_factor(True) + + lifecycle = assumptions.lifecycle + if lifecycle is not None: + factor *= lifecycle_factor(lifecycle.stage) + + return clamp(factor, *FACTOR_BAND) + + +def apply_adjustment(baseline: list[float], factors: list[float]) -> list[float]: + """Element-wise multiply ``baseline`` by ``factors``, flooring each at 0.0. + + Returns a NEW list — the input ``baseline`` is never mutated (the leakage + spec depends on this). Raises ``ValueError`` on a length mismatch: that is a + caller-contract violation, not junk data. + """ + if len(baseline) != len(factors): + raise ValueError( + f"baseline and factors must be equal length: {len(baseline)} != {len(factors)}" + ) + return [max(0.0, value * factor) for value, factor in zip(baseline, factors, strict=True)] + + +def coverage_verdict(scenario_total_units: float, on_hand_units: int | None) -> CoverageVerdict: + """Classify whether projected demand is covered by on-hand stock. + + Returns ``unknown`` when no inventory assumption was supplied. Otherwise: + ``covered`` when demand sits comfortably below stock, ``at_risk`` when it is + within ``COVERAGE_AT_RISK_BAND`` of stock, ``stockout`` when it exceeds that + band. Never raises. + """ + if on_hand_units is None: + return "unknown" + if on_hand_units <= 0: + return "stockout" if scenario_total_units > 0.0 else "at_risk" + upper = on_hand_units * (1.0 + COVERAGE_AT_RISK_BAND) + lower = on_hand_units * (1.0 - COVERAGE_AT_RISK_BAND) + if scenario_total_units > upper: + return "stockout" + if scenario_total_units >= lower: + return "at_risk" + return "covered" diff --git a/app/features/scenarios/models.py b/app/features/scenarios/models.py new file mode 100644 index 00000000..66e40c2d --- /dev/null +++ b/app/features/scenarios/models.py @@ -0,0 +1,70 @@ +"""Scenario plan ORM model. + +A ``scenario_plan`` row persists a saved what-if analysis: the raw +``ScenarioAssumptions`` *and* the full ``ScenarioComparison`` snapshot, both as +JSONB. Storing the snapshot (PRP-26 decision #3) means a reloaded plan +re-renders without recomputation — and without the original model artifact +still having to exist on disk. + +GOTCHA: SQLAlchemy reserves the declarative attribute name ``metadata``; the +JSONB columns are therefore named ``assumptions`` and ``comparison``. +""" + +from __future__ import annotations + +from typing import Any + +from sqlalchemy import CheckConstraint, Index, Integer, String +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.database import Base +from app.shared.models import TimestampMixin + +# The only adjustment method the MVP produces — guarded by a CHECK constraint. +SCENARIO_METHOD_HEURISTIC = "heuristic" + + +class ScenarioPlan(TimestampMixin, Base): + """A saved scenario plan. + + Attributes: + id: Surrogate primary key. + scenario_id: Unique external identifier (UUID hex, 32 chars). + name: Human-readable plan name. + store_id: Store the baseline model targets. + product_id: Product the baseline model targets. + run_id: Artifact key of the baseline model (model_{run_id}.joblib). + horizon: Number of days simulated. + assumptions: Raw ScenarioAssumptions as JSONB. + comparison: Full ScenarioComparison snapshot as JSONB. + method: Adjustment method — always 'heuristic' (CHECK-constrained). + """ + + __tablename__ = "scenario_plan" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + scenario_id: Mapped[str] = mapped_column(String(32), unique=True, index=True) + name: Mapped[str] = mapped_column(String(200), nullable=False) + store_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False) + product_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False) + run_id: Mapped[str] = mapped_column(String(32), index=True, nullable=False) + horizon: Mapped[int] = mapped_column(Integer, nullable=False) + + # JSONB blobs — never named ``metadata`` (SQLAlchemy reserves it). + assumptions: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False) + comparison: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False) + + method: Mapped[str] = mapped_column( + String(20), nullable=False, default=SCENARIO_METHOD_HEURISTIC + ) + + __table_args__ = ( + # GIN indexes for JSONB containment queries on either blob. + Index("ix_scenario_plan_assumptions_gin", "assumptions", postgresql_using="gin"), + Index("ix_scenario_plan_comparison_gin", "comparison", postgresql_using="gin"), + # Composite index for the common "plans for this store/product" query. + Index("ix_scenario_plan_store_product", "store_id", "product_id"), + # The MVP only ever produces heuristic comparisons. + CheckConstraint("method IN ('heuristic')", name="ck_scenario_plan_method"), + ) diff --git a/app/features/scenarios/routes.py b/app/features/scenarios/routes.py new file mode 100644 index 00000000..3e0bf46e --- /dev/null +++ b/app/features/scenarios/routes.py @@ -0,0 +1,204 @@ +"""API routes for the Scenario Simulation slice. + +Five endpoints back the ``Visualize → What-If Planner`` page: a stateless +``POST /scenarios/simulate`` plus CRUD over saved ``scenario_plan`` rows. + +Service-layer ``FileNotFoundError`` / ``ValueError`` map to RFC 7807 problem +responses via the ``app.core.exceptions`` ``ForecastLabError`` hierarchy +(``application/problem+json``) — a bogus ``run_id`` never surfaces as a 500. +""" + +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.core.exceptions import BadRequestError, DatabaseError, NotFoundError +from app.core.logging import get_logger +from app.features.scenarios.schemas import ( + CreateScenarioRequest, + ScenarioComparison, + ScenarioListResponse, + ScenarioPlanResponse, + SimulateScenarioRequest, +) +from app.features.scenarios.service import ScenarioService + +logger = get_logger(__name__) + +router = APIRouter(prefix="/scenarios", tags=["scenarios"]) + + +@router.post( + "/simulate", + response_model=ScenarioComparison, + status_code=status.HTTP_200_OK, + summary="Run a stateless what-if simulation", + description=""" +Run a baseline forecast for an existing trained model and apply deterministic +what-if adjustment factors. + +**Inputs:** +- `run_id`: artifact key of a baseline model (the `run_id` on a completed + predict/train job — `model_{run_id}.joblib`). +- `horizon`: number of days to simulate (1-90). +- `assumptions`: optional price / promotion / holiday / inventory / lifecycle + assumptions. Omit them all for a no-change baseline. + +**Output:** a `ScenarioComparison` — per-day baseline vs. scenario demand, +aggregate unit and revenue deltas, a coverage verdict, and a `method` +(`heuristic`) plus a `disclaimer`. The result is a deterministic post-forecast +multiplier, NOT a re-trained causal model. + +A bogus `run_id` returns a 404 problem response; an invalid artifact path +returns 400 — never a 500. +""", +) +async def simulate_scenario( + request: SimulateScenarioRequest, + db: AsyncSession = Depends(get_db), +) -> ScenarioComparison: + """Run a stateless scenario simulation. + + Args: + request: Baseline run_id, horizon, and what-if assumptions. + db: Async database session from dependency. + + Returns: + A baseline-vs-scenario comparison. + + Raises: + NotFoundError: When the model artifact is missing. + BadRequestError: When the request is otherwise invalid. + """ + try: + return await ScenarioService().simulate(db, request) + except FileNotFoundError as exc: + logger.warning("scenarios.simulate_not_found", run_id=request.run_id, error=str(exc)) + raise NotFoundError(message=str(exc)) from exc + except ValueError as exc: + logger.warning("scenarios.simulate_invalid", run_id=request.run_id, error=str(exc)) + raise BadRequestError(message=str(exc)) from exc + + +@router.post( + "", + response_model=ScenarioPlanResponse, + status_code=status.HTTP_201_CREATED, + summary="Save a scenario plan", + description=""" +Run a simulation and persist it as a named plan. + +The saved plan stores both the raw assumptions and the full comparison +snapshot, so a reloaded plan re-renders without recomputation. +""", +) +async def create_scenario( + request: CreateScenarioRequest, + db: AsyncSession = Depends(get_db), +) -> ScenarioPlanResponse: + """Persist a scenario plan. + + Args: + request: Plan name plus baseline run_id, horizon, and assumptions. + db: Async database session from dependency. + + Returns: + The saved plan with its embedded comparison snapshot. + + Raises: + NotFoundError: When the model artifact is missing. + BadRequestError: When the request is otherwise invalid. + DatabaseError: When the persistence operation fails. + """ + try: + return await ScenarioService().create_plan(db, request) + except FileNotFoundError as exc: + logger.warning("scenarios.create_not_found", run_id=request.run_id, error=str(exc)) + raise NotFoundError(message=str(exc)) from exc + except ValueError as exc: + logger.warning("scenarios.create_invalid", run_id=request.run_id, error=str(exc)) + raise BadRequestError(message=str(exc)) from exc + except SQLAlchemyError as exc: + logger.error("scenarios.create_db_error", error=str(exc), exc_info=True) + raise DatabaseError( + message="Failed to save scenario plan", + details={"error": str(exc)}, + ) from exc + + +@router.get( + "", + response_model=ScenarioListResponse, + summary="List saved scenario plans", + description="List saved scenario plans, newest first. Returns 200 + an " + "empty list when no plans exist.", +) +async def list_scenarios( + db: AsyncSession = Depends(get_db), + limit: int = Query(default=20, ge=1, le=100, description="Maximum plans to return."), + offset: int = Query(default=0, ge=0, description="Number of plans to skip."), +) -> ScenarioListResponse: + """List saved scenario plans. + + Args: + db: Async database session from dependency. + limit: Maximum plans to return (1-100). + offset: Number of plans to skip. + + Returns: + A page of saved plans plus the total count. + """ + return await ScenarioService().list_plans(db, limit=limit, offset=offset) + + +@router.get( + "/{scenario_id}", + response_model=ScenarioPlanResponse, + summary="Get a saved scenario plan", + description="Fetch one saved plan, including its embedded comparison snapshot.", +) +async def get_scenario( + scenario_id: str, + db: AsyncSession = Depends(get_db), +) -> ScenarioPlanResponse: + """Get a saved scenario plan by id. + + Args: + scenario_id: External identifier of the plan. + db: Async database session from dependency. + + Returns: + The saved plan with its embedded comparison snapshot. + + Raises: + NotFoundError: When no plan matches ``scenario_id``. + """ + plan = await ScenarioService().get_plan(db, scenario_id) + if plan is None: + raise NotFoundError(message=f"Scenario plan not found: {scenario_id}") + return plan + + +@router.delete( + "/{scenario_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete a saved scenario plan", + description="Delete a saved scenario plan by id.", +) +async def delete_scenario( + scenario_id: str, + db: AsyncSession = Depends(get_db), +) -> None: + """Delete a saved scenario plan. + + Args: + scenario_id: External identifier of the plan. + db: Async database session from dependency. + + Raises: + NotFoundError: When no plan matches ``scenario_id``. + """ + deleted = await ScenarioService().delete_plan(db, scenario_id) + if not deleted: + raise NotFoundError(message=f"Scenario plan not found: {scenario_id}") diff --git a/app/features/scenarios/schemas.py b/app/features/scenarios/schemas.py new file mode 100644 index 00000000..d5bc0d50 --- /dev/null +++ b/app/features/scenarios/schemas.py @@ -0,0 +1,314 @@ +"""Pydantic schemas for the Scenario Simulation slice. + +Two families of model live here: + +* **Request bodies** — ``SimulateScenarioRequest``, ``CreateScenarioRequest`` and + the ``*Assumption`` inputs. They carry ``ConfigDict(strict=True)`` to catch + silent coercion bugs on JSON-native types, and every ``date`` field carries a + ``Field(strict=False, ...)`` override so FastAPI's ``validate_python`` path + still accepts ISO-string dates (see ``docs/_base/SECURITY.md`` — "Pydantic v2 + strict mode on FastAPI request bodies"). +* **Responses** — ``ScenarioComparison``, ``ScenarioPlanResponse`` and the list + models. They use ``ConfigDict(from_attributes=True)`` and deliberately do NOT + set ``strict=True``. +""" + +from __future__ import annotations + +from datetime import date as date_type +from datetime import datetime +from typing import Annotated, Literal + +from pydantic import BaseModel, ConfigDict, Field + +# Promotion mechanics mirror data_platform.models.Promotion.kind. +PromotionKind = Literal["pct_off", "bogo", "bundle", "markdown"] +# Lifecycle stages a planner can force on the assumption form. +LifecycleStage = Literal["launch", "growth", "maturity", "decline"] +# Whether projected demand is covered by on-hand stock. +CoverageVerdict = Literal["covered", "at_risk", "stockout", "unknown"] + + +# ============================================================================= +# Assumption inputs (request fragments) +# ============================================================================= + + +class PriceAssumption(BaseModel): + """A relative price change applied over a future date window.""" + + model_config = ConfigDict(strict=True) + + change_pct: float = Field( + ..., + ge=-0.9, + le=5.0, + description="Relative price change as a fraction (-0.15 == 15% cheaper, " + "0.10 == 10% dearer).", + ) + start_date: date_type = Field( + ..., + strict=False, + description="First day the price change is in effect (inclusive).", + ) + end_date: date_type = Field( + ..., + strict=False, + description="Last day the price change is in effect (inclusive).", + ) + + +class PromotionAssumption(BaseModel): + """A promotion of a given kind running over a future date window.""" + + model_config = ConfigDict(strict=True) + + kind: PromotionKind = Field( + ..., + description="Promotion mechanic: pct_off, bogo, bundle, or markdown.", + ) + start_date: date_type = Field( + ..., + strict=False, + description="First day the promotion runs (inclusive).", + ) + end_date: date_type = Field( + ..., + strict=False, + description="Last day the promotion runs (inclusive).", + ) + + +class HolidayAssumption(BaseModel): + """Explicit holiday / event days that lift demand.""" + + model_config = ConfigDict(strict=True) + + # ``strict=False`` on the outer Field satisfies the strict-mode policy + # linter; the per-element ``Annotated[..., Field(strict=False)]`` is what + # actually lets each ISO-string date coerce — field-level strict does NOT + # propagate into list members. + dates: list[Annotated[date_type, Field(strict=False)]] = Field( + ..., + strict=False, + min_length=1, + description="Calendar dates treated as holiday / event days.", + ) + + +class InventoryAssumption(BaseModel): + """On-hand stock used only to derive a coverage verdict — never demand.""" + + model_config = ConfigDict(strict=True) + + on_hand_units: int = Field( + ..., + ge=0, + description="Units of stock on hand for the horizon. Caps coverage, not demand.", + ) + + +class LifecycleAssumption(BaseModel): + """A forced product lifecycle stage for the whole horizon.""" + + model_config = ConfigDict(strict=True) + + stage: LifecycleStage = Field( + ..., + description="Lifecycle stage override: launch, growth, maturity, or decline.", + ) + + +class ScenarioAssumptions(BaseModel): + """The full set of optional what-if assumptions. + + Every field is optional — an empty ``ScenarioAssumptions`` is the "nothing + changes" case and yields a scenario identical to the baseline. + """ + + model_config = ConfigDict(strict=True) + + price: PriceAssumption | None = Field(default=None, description="Price-change assumption.") + promotion: PromotionAssumption | None = Field(default=None, description="Promotion assumption.") + holiday: HolidayAssumption | None = Field(default=None, description="Holiday / event days.") + inventory: InventoryAssumption | None = Field( + default=None, description="On-hand stock for the coverage verdict." + ) + lifecycle: LifecycleAssumption | None = Field( + default=None, description="Lifecycle-stage override." + ) + + +# ============================================================================= +# Request bodies +# ============================================================================= + + +class SimulateScenarioRequest(BaseModel): + """Request body for ``POST /scenarios/simulate`` (stateless).""" + + model_config = ConfigDict(strict=True) + + run_id: str = Field( + ..., + min_length=1, + max_length=64, + description="Artifact key of a baseline model — the run_id stored on a " + "completed predict/train job (model_{run_id}.joblib).", + ) + horizon: int = Field( + ..., + ge=1, + le=90, + description="Number of days to simulate.", + ) + assumptions: ScenarioAssumptions = Field( + default_factory=ScenarioAssumptions, + description="Optional what-if assumptions. Omit for a no-change baseline.", + ) + name: str | None = Field( + default=None, + max_length=200, + description="Optional label echoed back; suggested name when saving a plan.", + ) + + +class CreateScenarioRequest(BaseModel): + """Request body for ``POST /scenarios`` — runs a simulation and persists it.""" + + model_config = ConfigDict(strict=True) + + name: str = Field( + ..., + min_length=1, + max_length=200, + description="Human-readable name for the saved plan.", + ) + run_id: str = Field( + ..., + min_length=1, + max_length=64, + description="Artifact key of the baseline model.", + ) + horizon: int = Field( + ..., + ge=1, + le=90, + description="Number of days to simulate.", + ) + assumptions: ScenarioAssumptions = Field( + default_factory=ScenarioAssumptions, + description="What-if assumptions for this plan.", + ) + + +# ============================================================================= +# Response models +# ============================================================================= + + +class ScenarioPoint(BaseModel): + """One horizon day: baseline vs. scenario demand and the factor applied.""" + + model_config = ConfigDict(from_attributes=True) + + date: date_type = Field(..., description="Forecast date.") + baseline: float = Field(..., description="Baseline forecast demand for the day.") + scenario: float = Field(..., description="Scenario-adjusted demand for the day.") + delta: float = Field(..., description="scenario minus baseline for the day.") + applied_factor: float = Field( + ..., + description="Combined deterministic multiplier applied on the day (1.0 == no change).", + ) + + +class ScenarioComparison(BaseModel): + """A full baseline-vs-scenario comparison for one (store, product) series.""" + + model_config = ConfigDict(from_attributes=True) + + store_id: int = Field(..., description="Store the baseline model targets.") + product_id: int = Field(..., description="Product the baseline model targets.") + model_type: str = Field(..., description="Model type of the baseline artifact.") + horizon: int = Field(..., ge=1, description="Number of days simulated.") + points: list[ScenarioPoint] = Field( + ..., + description="Per-day baseline / scenario series; length equals horizon.", + ) + baseline_total_units: float = Field(..., description="Summed baseline demand.") + scenario_total_units: float = Field(..., description="Summed scenario demand.") + units_delta: float = Field(..., description="scenario_total_units minus baseline_total_units.") + units_delta_pct: float = Field( + ..., + description="units_delta as a percentage of baseline; 0.0 when baseline is 0.", + ) + unit_price_used: float = Field( + ..., + description="Unit price used for the revenue estimate (most recent sale, " + "or a documented fallback).", + ) + baseline_revenue: float = Field(..., description="baseline_total_units * unit_price_used.") + scenario_revenue: float = Field(..., description="scenario_total_units * unit_price_used.") + revenue_delta: float = Field(..., description="scenario_revenue minus baseline_revenue.") + coverage_verdict: CoverageVerdict = Field( + ..., + description="covered / at_risk / stockout, or unknown when no inventory " + "assumption was supplied.", + ) + method: Literal["heuristic"] = Field( + ..., + description="Always 'heuristic' — the result is a deterministic post-forecast " + "multiplier, not a re-trained causal model.", + ) + disclaimer: str = Field( + ..., + description="Plain-language caveat that the numbers are heuristic estimates.", + ) + generated_at: datetime = Field(..., description="When the comparison was computed (UTC).") + + +class ScenarioPlanResponse(BaseModel): + """A persisted scenario plan, including the embedded comparison snapshot.""" + + model_config = ConfigDict(from_attributes=True) + + scenario_id: str = Field(..., description="Unique external identifier of the plan.") + name: str = Field(..., description="Human-readable plan name.") + store_id: int = Field(..., description="Store the plan targets.") + product_id: int = Field(..., description="Product the plan targets.") + run_id: str = Field(..., description="Artifact key of the baseline model.") + horizon: int = Field(..., ge=1, description="Number of days simulated.") + method: str = Field(..., description="Adjustment method — always 'heuristic'.") + created_at: datetime = Field(..., description="When the plan was saved (UTC).") + assumptions: ScenarioAssumptions = Field( + ..., description="The raw what-if assumptions the plan was built from." + ) + comparison: ScenarioComparison = Field( + ..., description="The full baseline-vs-scenario snapshot, re-rendered without recompute." + ) + + +class ScenarioListItem(BaseModel): + """A compact row in the saved-plans list.""" + + model_config = ConfigDict(from_attributes=True) + + scenario_id: str = Field(..., description="Unique external identifier of the plan.") + name: str = Field(..., description="Human-readable plan name.") + store_id: int = Field(..., description="Store the plan targets.") + product_id: int = Field(..., description="Product the plan targets.") + horizon: int = Field(..., ge=1, description="Number of days simulated.") + units_delta: float = Field(..., description="Summed scenario-minus-baseline demand.") + revenue_delta: float = Field(..., description="Scenario-minus-baseline revenue.") + created_at: datetime = Field(..., description="When the plan was saved (UTC).") + + +class ScenarioListResponse(BaseModel): + """A page of saved scenario plans, newest first.""" + + model_config = ConfigDict(from_attributes=True) + + scenarios: list[ScenarioListItem] = Field( + ..., description="Saved plans for the current page; empty when none exist." + ) + total: int = Field(..., ge=0, description="Total saved plans matching the query.") diff --git a/app/features/scenarios/service.py b/app/features/scenarios/service.py new file mode 100644 index 00000000..6fb19ec6 --- /dev/null +++ b/app/features/scenarios/service.py @@ -0,0 +1,351 @@ +"""Service layer for the Scenario Simulation slice. + +``ScenarioService`` does two things: + +* **simulate** — resolve a baseline model artifact, run its forecast, apply the + pure deterministic factors from ``adjustments.py``, and return a + ``ScenarioComparison``. Stateless. +* **CRUD** — persist a comparison as a named ``scenario_plan`` row, then list / + fetch / delete saved plans. + +DECISIONS LOCKED (PRP-26 #2): this service must NOT import a sibling slice's +``service.py``. It imports only the stable lower-level building block +``load_model_bundle`` from ``forecasting/persistence.py`` and produces the +baseline forecast by calling ``bundle.model.predict(horizon)`` directly — +replicating the ``ForecastPoint``-construction block of +``ForecastingService.predict`` rather than calling that class. Read-only ORM +imports of sibling ``models.py`` (``data_platform``) are allowed. +""" + +from __future__ import annotations + +import uuid +from datetime import UTC, date, datetime, timedelta +from pathlib import Path + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import get_settings +from app.core.logging import get_logger +from app.features.data_platform.models import SalesDaily +from app.features.forecasting.persistence import ModelBundle, load_model_bundle +from app.features.scenarios import adjustments +from app.features.scenarios.models import SCENARIO_METHOD_HEURISTIC, ScenarioPlan +from app.features.scenarios.schemas import ( + CreateScenarioRequest, + ScenarioAssumptions, + ScenarioComparison, + ScenarioListItem, + ScenarioListResponse, + ScenarioPlanResponse, + ScenarioPoint, + SimulateScenarioRequest, +) + +logger = get_logger(__name__) + +# Plain-language caveat stamped on every comparison — the NIST-AI-RMF +# transparency control against over-trusting a heuristic number. +HEURISTIC_DISCLAIMER = ( + "Heuristic estimate: this scenario applies fixed, deterministic adjustment " + "factors to a baseline forecast — it is not a re-trained, causal model. " + "Treat the demand and revenue deltas as directional planning signals, not " + "precise predictions." +) + +# Fallback unit price when a (store, product) has no sales history. +DEFAULT_UNIT_PRICE = 1.0 + + +class ScenarioService: + """Stateless simulation plus saved-plan CRUD for scenario planning.""" + + # -- Simulation -------------------------------------------------------- + + async def simulate( + self, db: AsyncSession, request: SimulateScenarioRequest + ) -> ScenarioComparison: + """Run a baseline forecast and apply the what-if assumptions. + + Args: + db: Database session (used only to estimate a unit price). + request: The baseline ``run_id``, horizon, and assumptions. + + Returns: + A full baseline-vs-scenario comparison. + + Raises: + FileNotFoundError: When no model artifact exists for ``run_id``. + ValueError: When the artifact path is invalid or its metadata is + missing the store / product identity. + """ + bundle = self._load_baseline_bundle(request.run_id) + + store_id_raw = bundle.metadata.get("store_id") + product_id_raw = bundle.metadata.get("product_id") + if store_id_raw is None or product_id_raw is None: + raise ValueError( + f"Model artifact for run_id '{request.run_id}' is missing " + "store_id / product_id metadata." + ) + store_id = int(str(store_id_raw)) + product_id = int(str(product_id_raw)) + + # Replicate the ForecastingService.predict body (DECISIONS LOCKED #2). + raw_forecast = bundle.model.predict(request.horizon) + baseline_values = [float(value) for value in raw_forecast] + start_date = self._forecast_start_date(bundle.metadata.get("train_end_date")) + + # Per-day deterministic factors — adjustments.py is pure. + factors: list[float] = [] + for offset in range(request.horizon): + point_date = start_date + timedelta(days=offset) + factors.append(adjustments.combined_daily_factor(point_date, request.assumptions)) + scenario_values = adjustments.apply_adjustment(baseline_values, factors) + + points = [ + ScenarioPoint( + date=start_date + timedelta(days=offset), + baseline=baseline_values[offset], + scenario=scenario_values[offset], + delta=scenario_values[offset] - baseline_values[offset], + applied_factor=factors[offset], + ) + for offset in range(request.horizon) + ] + + baseline_total = sum(baseline_values) + scenario_total = sum(scenario_values) + units_delta = scenario_total - baseline_total + units_delta_pct = (units_delta / baseline_total * 100.0) if baseline_total > 0 else 0.0 + + unit_price = await self._latest_unit_price(db, store_id, product_id) + baseline_revenue = baseline_total * unit_price + scenario_revenue = scenario_total * unit_price + + inventory = request.assumptions.inventory + on_hand = inventory.on_hand_units if inventory is not None else None + verdict = adjustments.coverage_verdict(scenario_total, on_hand) + + logger.info( + "scenarios.simulated", + run_id=request.run_id, + store_id=store_id, + product_id=product_id, + horizon=request.horizon, + model_type=bundle.config.model_type, + units_delta=round(units_delta, 4), + coverage_verdict=verdict, + ) + + return ScenarioComparison( + store_id=store_id, + product_id=product_id, + model_type=bundle.config.model_type, + horizon=request.horizon, + points=points, + baseline_total_units=baseline_total, + scenario_total_units=scenario_total, + units_delta=units_delta, + units_delta_pct=units_delta_pct, + unit_price_used=unit_price, + baseline_revenue=baseline_revenue, + scenario_revenue=scenario_revenue, + revenue_delta=scenario_revenue - baseline_revenue, + coverage_verdict=verdict, + method="heuristic", + disclaimer=HEURISTIC_DISCLAIMER, + generated_at=datetime.now(UTC), + ) + + # -- Persistence ------------------------------------------------------- + + async def create_plan( + self, db: AsyncSession, request: CreateScenarioRequest + ) -> ScenarioPlanResponse: + """Run a simulation and persist it as a named scenario plan. + + Args: + db: Database session. + request: Plan name plus the baseline / horizon / assumptions. + + Returns: + The saved plan with its embedded comparison snapshot. + + Raises: + FileNotFoundError: When no model artifact exists for ``run_id``. + ValueError: When the artifact path or its metadata is invalid. + """ + comparison = await self.simulate( + db, + SimulateScenarioRequest( + run_id=request.run_id, + horizon=request.horizon, + assumptions=request.assumptions, + name=request.name, + ), + ) + + plan = ScenarioPlan( + scenario_id=uuid.uuid4().hex, + name=request.name, + store_id=comparison.store_id, + product_id=comparison.product_id, + run_id=request.run_id, + horizon=request.horizon, + # JSONB cannot store Python date/datetime — dump in JSON mode. + assumptions=request.assumptions.model_dump(mode="json"), + comparison=comparison.model_dump(mode="json"), + method=SCENARIO_METHOD_HEURISTIC, + ) + db.add(plan) + await db.commit() + await db.refresh(plan) + + logger.info( + "scenarios.plan_created", + scenario_id=plan.scenario_id, + store_id=plan.store_id, + product_id=plan.product_id, + ) + return self._to_plan_response(plan) + + async def list_plans(self, db: AsyncSession, limit: int, offset: int) -> ScenarioListResponse: + """List saved scenario plans, newest first. + + Args: + db: Database session. + limit: Maximum plans to return. + offset: Number of plans to skip. + + Returns: + A page of plan list items plus the total count. + """ + total = int(await db.scalar(select(func.count()).select_from(ScenarioPlan)) or 0) + + rows = ( + ( + await db.execute( + select(ScenarioPlan) + .order_by(ScenarioPlan.created_at.desc(), ScenarioPlan.id.desc()) + .limit(limit) + .offset(offset) + ) + ) + .scalars() + .all() + ) + return ScenarioListResponse( + scenarios=[self._to_list_item(row) for row in rows], + total=total, + ) + + async def get_plan(self, db: AsyncSession, scenario_id: str) -> ScenarioPlanResponse | None: + """Fetch one saved plan by its external id, or ``None`` when absent.""" + plan = await db.scalar(select(ScenarioPlan).where(ScenarioPlan.scenario_id == scenario_id)) + if plan is None: + return None + return self._to_plan_response(plan) + + async def delete_plan(self, db: AsyncSession, scenario_id: str) -> bool: + """Delete a saved plan; return ``True`` when a row was removed.""" + plan = await db.scalar(select(ScenarioPlan).where(ScenarioPlan.scenario_id == scenario_id)) + if plan is None: + return False + await db.delete(plan) + await db.commit() + logger.info("scenarios.plan_deleted", scenario_id=scenario_id) + return True + + # -- Internal helpers -------------------------------------------------- + + def _load_baseline_bundle(self, run_id: str) -> ModelBundle: + """Resolve and load the baseline model artifact for ``run_id``. + + Mirrors the load-bearing path-traversal guard in + ``ForecastingService.predict``: reject a non-``.joblib`` suffix and any + path that escapes the configured artifacts directory. + """ + settings = get_settings() + artifacts_dir = Path(settings.forecast_model_artifacts_dir).resolve() + model_path = (artifacts_dir / f"model_{run_id}.joblib").resolve() + + if model_path.suffix != ".joblib": + raise ValueError(f"Invalid model path for run_id '{run_id}'.") + try: + model_path.relative_to(artifacts_dir) + except ValueError: + raise ValueError(f"Invalid model path for run_id '{run_id}'.") from None + if not model_path.exists(): + raise FileNotFoundError(f"No model artifact found for run_id '{run_id}'.") + + return load_model_bundle(model_path) + + @staticmethod + def _forecast_start_date(train_end_raw: object) -> date: + """Return the first forecast day — train_end_date + 1, or today + 1. + + ``train_end_date`` is persisted as an ISO string in the bundle metadata; + when it is absent the forecast simply starts tomorrow. + """ + if isinstance(train_end_raw, str): + return date.fromisoformat(train_end_raw) + timedelta(days=1) + return datetime.now(UTC).date() + timedelta(days=1) + + async def _latest_unit_price(self, db: AsyncSession, store_id: int, product_id: int) -> float: + """Estimate a unit price from the most recent sale of this grain. + + Falls back to ``DEFAULT_UNIT_PRICE`` (and logs a warning) when the + grain has no sales history. + """ + price = await db.scalar( + select(SalesDaily.unit_price) + .where(SalesDaily.store_id == store_id, SalesDaily.product_id == product_id) + .order_by(SalesDaily.date.desc()) + .limit(1) + ) + if price is None: + logger.warning( + "scenarios.unit_price_fallback", + store_id=store_id, + product_id=product_id, + fallback=DEFAULT_UNIT_PRICE, + ) + return DEFAULT_UNIT_PRICE + return float(price) + + @staticmethod + def _to_plan_response(plan: ScenarioPlan) -> ScenarioPlanResponse: + """Build a full plan response from a persisted row. + + The JSONB blobs round-trip cleanly: ``ScenarioComparison`` is not strict, + and every ``date`` field of ``ScenarioAssumptions`` carries + ``Field(strict=False)``, so the stored ISO strings re-validate. + """ + return ScenarioPlanResponse( + scenario_id=plan.scenario_id, + name=plan.name, + store_id=plan.store_id, + product_id=plan.product_id, + run_id=plan.run_id, + horizon=plan.horizon, + method=plan.method, + created_at=plan.created_at, + assumptions=ScenarioAssumptions.model_validate(plan.assumptions), + comparison=ScenarioComparison.model_validate(plan.comparison), + ) + + @staticmethod + def _to_list_item(plan: ScenarioPlan) -> ScenarioListItem: + """Build a compact list row, reading the deltas from the snapshot.""" + return ScenarioListItem( + scenario_id=plan.scenario_id, + name=plan.name, + store_id=plan.store_id, + product_id=plan.product_id, + horizon=plan.horizon, + units_delta=float(plan.comparison.get("units_delta", 0.0)), + revenue_delta=float(plan.comparison.get("revenue_delta", 0.0)), + created_at=plan.created_at, + ) diff --git a/app/features/scenarios/tests/__init__.py b/app/features/scenarios/tests/__init__.py new file mode 100644 index 00000000..b9535cd8 --- /dev/null +++ b/app/features/scenarios/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the scenarios vertical slice.""" diff --git a/app/features/scenarios/tests/conftest.py b/app/features/scenarios/tests/conftest.py new file mode 100644 index 00000000..d899ff2c --- /dev/null +++ b/app/features/scenarios/tests/conftest.py @@ -0,0 +1,99 @@ +"""Test fixtures for the scenarios slice. + +Integration tests run against a real PostgreSQL database (``docker compose up +-d`` required). The ``trained_model`` fixture writes a real model bundle into +the configured artifacts directory so ``POST /scenarios/simulate`` can resolve +it, exactly as a completed predict job would. + +``scenario_plan`` is a slice-private table — no seeder or demo writes it — so +the teardown safely wipes it whole rather than relying on a row marker. +""" + +import uuid +from collections.abc import AsyncGenerator, Generator +from pathlib import Path + +import numpy as np +import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy import delete +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app.core.config import get_settings +from app.core.database import get_db +from app.features.forecasting.models import NaiveForecaster +from app.features.forecasting.persistence import ModelBundle, save_model_bundle +from app.features.forecasting.schemas import NaiveModelConfig +from app.features.scenarios.models import ScenarioPlan +from app.main import app + +# Store / product the test bundle is trained for. High IDs that no seeder uses, +# so the revenue calc deterministically hits the unit-price fallback. +TEST_STORE_ID = 990001 +TEST_PRODUCT_ID = 990002 +# train_end_date baked into the bundle metadata — the forecast starts the next day. +TEST_TRAIN_END_DATE = "2026-06-30" + + +@pytest.fixture +async def db_session() -> AsyncGenerator[AsyncSession, None]: + """Yield an async session, then wipe every scenario_plan row on teardown.""" + settings = get_settings() + engine = create_async_engine(settings.database_url, echo=False) + async_session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + async with async_session_maker() as session: + try: + yield session + finally: + await session.execute(delete(ScenarioPlan)) + await session.commit() + + await engine.dispose() + + +@pytest.fixture +async def client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]: + """Create a test client with the database dependency overridden.""" + + async def override_get_db() -> AsyncGenerator[AsyncSession, None]: + yield db_session + + app.dependency_overrides[get_db] = override_get_db + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac: + yield ac + + app.dependency_overrides.pop(get_db, None) + + +@pytest.fixture +def trained_model() -> Generator[str, None, None]: + """Save a real fitted naive-model bundle on disk; yield its run_id. + + The bundle lands in ``settings.forecast_model_artifacts_dir`` as + ``model_{run_id}.joblib`` — the exact artifact key ``ScenarioService`` + resolves. The file is removed on teardown. + """ + settings = get_settings() + artifacts_dir = Path(settings.forecast_model_artifacts_dir) + artifacts_dir.mkdir(parents=True, exist_ok=True) + + run_id = uuid.uuid4().hex[:12] + model = NaiveForecaster() + model.fit(np.array([10.0, 12.0, 11.0, 13.0, 9.0, 14.0, 10.0], dtype=np.float64)) + bundle = ModelBundle( + model=model, + config=NaiveModelConfig(), + metadata={ + "store_id": TEST_STORE_ID, + "product_id": TEST_PRODUCT_ID, + "train_end_date": TEST_TRAIN_END_DATE, + "n_observations": 7, + }, + ) + save_model_bundle(bundle, artifacts_dir / f"model_{run_id}") + + yield run_id + + (artifacts_dir / f"model_{run_id}.joblib").unlink(missing_ok=True) diff --git a/app/features/scenarios/tests/test_adjustments.py b/app/features/scenarios/tests/test_adjustments.py new file mode 100644 index 00000000..1cde5fe6 --- /dev/null +++ b/app/features/scenarios/tests/test_adjustments.py @@ -0,0 +1,206 @@ +"""Unit tests for the pure scenario adjustment engine. + +These run without a database (-m "not integration"): every function in +``adjustments.py`` is pure. The tests assert *direction and bounds* — a price +cut lifts demand, the clamp keeps a factor in band — not exact magnitudes, so +re-tuning the heuristic constants does not break them. +""" + +from datetime import date + +import pytest + +from app.features.scenarios import adjustments +from app.features.scenarios.schemas import ( + HolidayAssumption, + InventoryAssumption, + LifecycleAssumption, + PriceAssumption, + PromotionAssumption, + ScenarioAssumptions, +) + +# ============================================================================= +# clamp +# ============================================================================= + + +def test_clamp_inside_range_returns_value() -> None: + """A value already in range is returned unchanged.""" + assert adjustments.clamp(0.5, 0.1, 5.0) == 0.5 + + +def test_clamp_below_and_above() -> None: + """Values outside the range snap to the nearest bound.""" + assert adjustments.clamp(-3.0, 0.1, 5.0) == 0.1 + assert adjustments.clamp(99.0, 0.1, 5.0) == 5.0 + + +# ============================================================================= +# price_factor +# ============================================================================= + + +def test_price_cut_lifts_demand() -> None: + """A price cut (negative change) yields a factor above 1.""" + assert adjustments.price_factor(-0.15) > 1.0 + + +def test_price_rise_drags_demand() -> None: + """A price rise (positive change) yields a factor below 1.""" + assert adjustments.price_factor(0.20) < 1.0 + + +def test_price_factor_no_change_is_neutral() -> None: + """A zero price change is exactly neutral.""" + assert adjustments.price_factor(0.0) == 1.0 + + +def test_price_factor_tolerates_non_positive_price() -> None: + """A change of -100% or worse clamps to the upper band, never raises.""" + assert adjustments.price_factor(-1.0) == adjustments.FACTOR_BAND[1] + assert adjustments.price_factor(-5.0) == adjustments.FACTOR_BAND[1] + + +def test_price_factor_stays_in_band() -> None: + """The factor never escapes the clamp band for extreme inputs.""" + lo, hi = adjustments.FACTOR_BAND + for change in (-0.95, -0.5, 0.0, 1.0, 5.0): + assert lo <= adjustments.price_factor(change) <= hi + + +# ============================================================================= +# promotion_factor / holiday_factor / lifecycle_factor +# ============================================================================= + + +def test_promotion_factor_known_kinds_lift_demand() -> None: + """Every known promotion kind lifts demand when active.""" + for kind in ("pct_off", "bogo", "bundle", "markdown"): + assert adjustments.promotion_factor(kind, active=True) > 1.0 + + +def test_promotion_factor_inactive_or_unknown_is_neutral() -> None: + """An inactive promotion or an unknown kind is neutral.""" + assert adjustments.promotion_factor("pct_off", active=False) == 1.0 + assert adjustments.promotion_factor("mystery", active=True) == 1.0 + + +def test_holiday_factor() -> None: + """A holiday lifts demand; a non-holiday is neutral.""" + assert adjustments.holiday_factor(True) > 1.0 + assert adjustments.holiday_factor(False) == 1.0 + + +def test_lifecycle_factor_known_stages() -> None: + """Known lifecycle stages map to their documented multipliers.""" + assert adjustments.lifecycle_factor("launch") > 1.0 + assert adjustments.lifecycle_factor("maturity") == 1.0 + assert adjustments.lifecycle_factor("decline") < 1.0 + + +def test_lifecycle_factor_none_or_unknown_is_neutral() -> None: + """``None`` and an unknown stage are neutral, never an exception.""" + assert adjustments.lifecycle_factor(None) == 1.0 + assert adjustments.lifecycle_factor("zombie") == 1.0 + + +# ============================================================================= +# combined_daily_factor +# ============================================================================= + + +def test_combined_factor_empty_assumptions_is_neutral() -> None: + """An empty ScenarioAssumptions yields exactly 1.0 for any day.""" + assert adjustments.combined_daily_factor(date(2026, 6, 1), ScenarioAssumptions()) == 1.0 + + +def test_combined_factor_applies_price_inside_window() -> None: + """A price assumption applies only inside its date window.""" + assumptions = ScenarioAssumptions( + price=PriceAssumption( + change_pct=-0.20, start_date=date(2026, 6, 5), end_date=date(2026, 6, 10) + ) + ) + inside = adjustments.combined_daily_factor(date(2026, 6, 7), assumptions) + outside = adjustments.combined_daily_factor(date(2026, 6, 20), assumptions) + assert inside > 1.0 + assert outside == 1.0 + + +def test_combined_factor_stacks_promotion_and_holiday() -> None: + """Overlapping promotion and holiday assumptions compound multiplicatively.""" + day = date(2026, 6, 7) + assumptions = ScenarioAssumptions( + promotion=PromotionAssumption( + kind="bogo", start_date=date(2026, 6, 1), end_date=date(2026, 6, 30) + ), + holiday=HolidayAssumption(dates=[day]), + ) + assert adjustments.combined_daily_factor(day, assumptions) > 1.0 + + +def test_combined_factor_is_clamped() -> None: + """Even a stack of strong uplifts stays within the clamp band.""" + lo, hi = adjustments.FACTOR_BAND + assumptions = ScenarioAssumptions( + price=PriceAssumption( + change_pct=-0.9, start_date=date(2026, 6, 1), end_date=date(2026, 6, 30) + ), + promotion=PromotionAssumption( + kind="bogo", start_date=date(2026, 6, 1), end_date=date(2026, 6, 30) + ), + holiday=HolidayAssumption(dates=[date(2026, 6, 7)]), + lifecycle=LifecycleAssumption(stage="launch"), + ) + assert lo <= adjustments.combined_daily_factor(date(2026, 6, 7), assumptions) <= hi + + +# ============================================================================= +# apply_adjustment +# ============================================================================= + + +def test_apply_adjustment_element_wise() -> None: + """Each baseline value is multiplied by its matching factor.""" + assert adjustments.apply_adjustment([10.0, 20.0], [1.5, 0.5]) == [15.0, 10.0] + + +def test_apply_adjustment_floors_at_zero() -> None: + """A negative product is floored at 0.0 — demand is never negative.""" + assert adjustments.apply_adjustment([10.0], [-1.0]) == [0.0] + + +def test_apply_adjustment_length_mismatch_raises() -> None: + """A length mismatch is a caller-contract violation and raises ValueError.""" + with pytest.raises(ValueError, match="equal length"): + adjustments.apply_adjustment([1.0, 2.0], [1.0]) + + +# ============================================================================= +# coverage_verdict +# ============================================================================= + + +def test_coverage_verdict_unknown_without_inventory() -> None: + """No inventory assumption yields an 'unknown' verdict.""" + assert adjustments.coverage_verdict(100.0, None) == "unknown" + + +def test_coverage_verdict_covered_at_risk_stockout() -> None: + """Demand vs. on-hand stock maps to the three coverage bands.""" + assert adjustments.coverage_verdict(50.0, 100) == "covered" + assert adjustments.coverage_verdict(100.0, 100) == "at_risk" + assert adjustments.coverage_verdict(500.0, 100) == "stockout" + + +def test_coverage_verdict_zero_stock() -> None: + """Zero stock is a stockout when any demand exists.""" + assert adjustments.coverage_verdict(10.0, 0) == "stockout" + assert adjustments.coverage_verdict(0.0, 0) == "at_risk" + + +def test_coverage_verdict_uses_inventory_assumption_field() -> None: + """The verdict reads on_hand_units straight off the assumption model.""" + inventory = InventoryAssumption(on_hand_units=100) + assert adjustments.coverage_verdict(50.0, inventory.on_hand_units) == "covered" diff --git a/app/features/scenarios/tests/test_leakage.py b/app/features/scenarios/tests/test_leakage.py new file mode 100644 index 00000000..256947b7 --- /dev/null +++ b/app/features/scenarios/tests/test_leakage.py @@ -0,0 +1,94 @@ +"""Leakage spec for scenario simulation — LOAD-BEARING. + +This file IS the spec, mirroring the precedent of +``app/features/featuresets/tests/test_leakage.py``: it must NEVER be weakened +to make a feature pass (AGENTS.md § Safety). + +The invariant: a scenario adjustment touches ONLY horizon (future) points. It +applies a deterministic post-forecast multiplier to the baseline forecast and +can never reach back into, read, or mutate the historical target series. + +Concretely this spec asserts: + +1. ``apply_adjustment`` returns a NEW list and never mutates its ``baseline`` + input, and the adjusted series has exactly ``horizon`` points. +2. An assumption window that falls entirely BEFORE the forecast start + contributes factor ``1.0`` to every horizon day — it cannot affect history, + and it cannot affect the future either. +3. A day outside any assumption window contributes factor ``1.0``. +4. An empty ``ScenarioAssumptions`` leaves the baseline exactly unchanged. +""" + +from datetime import date, timedelta + +from app.features.scenarios import adjustments +from app.features.scenarios.schemas import PriceAssumption, ScenarioAssumptions + +# A deterministic forecast horizon used throughout this spec. +_FORECAST_START = date(2026, 7, 1) +_HORIZON = 14 +_HORIZON_DATES = [_FORECAST_START + timedelta(days=offset) for offset in range(_HORIZON)] + + +def test_apply_adjustment_does_not_mutate_baseline() -> None: + """``apply_adjustment`` returns a new list; the input baseline is untouched.""" + baseline = [10.0] * _HORIZON + baseline_snapshot = list(baseline) + factors = [1.5] * _HORIZON + + adjusted = adjustments.apply_adjustment(baseline, factors) + + assert adjusted is not baseline, "apply_adjustment must return a NEW list" + assert baseline == baseline_snapshot, "the input baseline must never be mutated" + assert len(adjusted) == _HORIZON, "the adjusted series must keep the horizon length" + + +def test_assumption_window_before_forecast_start_has_no_effect() -> None: + """A price window entirely before the forecast start contributes no factor. + + The window 2026-06-01 .. 2026-06-15 ends before the forecast starts on + 2026-07-01. Every horizon day must therefore receive factor 1.0 — the + adjustment can never reach a date outside the future horizon. + """ + past_window = ScenarioAssumptions( + price=PriceAssumption( + change_pct=-0.30, + start_date=date(2026, 6, 1), + end_date=date(2026, 6, 15), + ) + ) + factors = [ + adjustments.combined_daily_factor(point_date, past_window) for point_date in _HORIZON_DATES + ] + assert factors == [1.0] * _HORIZON, "a pre-forecast window must not affect the horizon" + + +def test_out_of_window_days_contribute_unit_factor() -> None: + """Only days inside the assumption window are adjusted; the rest stay 1.0. + + The window covers exactly the first three horizon days; days 4..14 must be + untouched (factor 1.0). + """ + windowed = ScenarioAssumptions( + price=PriceAssumption( + change_pct=-0.25, + start_date=_HORIZON_DATES[0], + end_date=_HORIZON_DATES[2], + ) + ) + factors = [ + adjustments.combined_daily_factor(point_date, windowed) for point_date in _HORIZON_DATES + ] + assert all(factor > 1.0 for factor in factors[:3]), "in-window days must be adjusted" + assert factors[3:] == [1.0] * (_HORIZON - 3), "out-of-window days must stay neutral" + + +def test_empty_assumptions_leave_baseline_unchanged() -> None: + """With no assumptions the scenario series equals the baseline exactly.""" + baseline = [float(value) for value in range(1, _HORIZON + 1)] + factors = [ + adjustments.combined_daily_factor(point_date, ScenarioAssumptions()) + for point_date in _HORIZON_DATES + ] + scenario = adjustments.apply_adjustment(baseline, factors) + assert scenario == baseline, "an empty scenario must not move the baseline" diff --git a/app/features/scenarios/tests/test_routes_integration.py b/app/features/scenarios/tests/test_routes_integration.py new file mode 100644 index 00000000..dd9b7e8d --- /dev/null +++ b/app/features/scenarios/tests/test_routes_integration.py @@ -0,0 +1,166 @@ +"""Integration tests for the scenarios routes. + +Runs against a real PostgreSQL database and a real model bundle on disk — the +full path from HTTP request through artifact resolution, forecast, adjustment, +and persistence. Requires ``docker compose up -d``. +""" + +import uuid + +import pytest +from httpx import AsyncClient +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.features.scenarios.models import ScenarioPlan + +# A price window covering the test bundle's 14-day horizon (train_end 2026-06-30). +_PRICE_ASSUMPTION = { + "price": {"change_pct": -0.15, "start_date": "2026-07-01", "end_date": "2026-07-14"}, +} + + +@pytest.mark.integration +@pytest.mark.asyncio +class TestSimulate: + """Integration tests for POST /scenarios/simulate.""" + + async def test_simulate_happy_path(self, client: AsyncClient, trained_model: str) -> None: + """A price-cut simulation returns a full, well-formed comparison.""" + response = await client.post( + "/scenarios/simulate", + json={"run_id": trained_model, "horizon": 14, "assumptions": _PRICE_ASSUMPTION}, + ) + + assert response.status_code == 200 + data = response.json() + + assert len(data["points"]) == 14 + assert data["horizon"] == 14 + assert data["method"] == "heuristic" + assert data["disclaimer"], "every comparison must carry a non-empty disclaimer" + # A price cut lifts demand — the scenario total must exceed the baseline. + assert data["units_delta"] > 0.0 + assert data["scenario_total_units"] > data["baseline_total_units"] + for point in data["points"]: + assert point["applied_factor"] > 1.0 + + async def test_simulate_empty_assumptions_equals_baseline( + self, client: AsyncClient, trained_model: str + ) -> None: + """An empty ScenarioAssumptions yields scenario == baseline, all deltas 0.""" + response = await client.post( + "/scenarios/simulate", + json={"run_id": trained_model, "horizon": 10, "assumptions": {}}, + ) + + assert response.status_code == 200 + data = response.json() + + assert data["units_delta"] == 0.0 + assert data["revenue_delta"] == 0.0 + assert data["coverage_verdict"] == "unknown" + for point in data["points"]: + assert point["delta"] == 0.0 + assert point["applied_factor"] == 1.0 + + async def test_simulate_bogus_run_id_returns_404(self, client: AsyncClient) -> None: + """A run_id with no artifact returns an RFC 7807 404 — never a 500.""" + response = await client.post( + "/scenarios/simulate", + json={"run_id": "does-not-exist-999", "horizon": 14, "assumptions": {}}, + ) + + assert response.status_code == 404 + assert response.status_code != 500 + assert "application/problem+json" in response.headers.get("content-type", "") + + async def test_simulate_invalid_horizon_rejected(self, client: AsyncClient) -> None: + """horizon below the ge=1 bound returns 422.""" + response = await client.post( + "/scenarios/simulate", + json={"run_id": "anything", "horizon": 0, "assumptions": {}}, + ) + assert response.status_code == 422 + + +@pytest.mark.integration +@pytest.mark.asyncio +class TestScenarioPlanCrud: + """Integration tests for the scenario_plan CRUD endpoints.""" + + async def test_crud_round_trip(self, client: AsyncClient, trained_model: str) -> None: + """A plan can be created, listed, fetched, and deleted.""" + create = await client.post( + "/scenarios", + json={ + "name": "Summer price cut", + "run_id": trained_model, + "horizon": 14, + "assumptions": _PRICE_ASSUMPTION, + }, + ) + assert create.status_code == 201 + plan = create.json() + scenario_id = plan["scenario_id"] + assert plan["name"] == "Summer price cut" + assert plan["method"] == "heuristic" + assert len(plan["comparison"]["points"]) == 14 + + listed = await client.get("/scenarios") + assert listed.status_code == 200 + list_data = listed.json() + assert list_data["total"] >= 1 + assert scenario_id in {item["scenario_id"] for item in list_data["scenarios"]} + + fetched = await client.get(f"/scenarios/{scenario_id}") + assert fetched.status_code == 200 + assert fetched.json()["comparison"]["units_delta"] > 0.0 + + deleted = await client.delete(f"/scenarios/{scenario_id}") + assert deleted.status_code == 204 + + missing = await client.get(f"/scenarios/{scenario_id}") + assert missing.status_code == 404 + + async def test_list_scenarios_empty_is_200(self, client: AsyncClient) -> None: + """GET /scenarios returns 200 + an empty list, never 404.""" + response = await client.get("/scenarios") + assert response.status_code == 200 + data = response.json() + assert isinstance(data["scenarios"], list) + assert data["total"] >= 0 + + async def test_get_missing_plan_returns_404(self, client: AsyncClient) -> None: + """Fetching an unknown scenario_id returns 404.""" + response = await client.get(f"/scenarios/{uuid.uuid4().hex}") + assert response.status_code == 404 + + async def test_delete_missing_plan_returns_404(self, client: AsyncClient) -> None: + """Deleting an unknown scenario_id returns 404.""" + response = await client.delete(f"/scenarios/{uuid.uuid4().hex}") + assert response.status_code == 404 + + +@pytest.mark.integration +@pytest.mark.asyncio +class TestScenarioPlanModel: + """Constraint tests for the ScenarioPlan ORM model.""" + + async def test_method_check_constraint(self, db_session: AsyncSession) -> None: + """The method CHECK constraint rejects any value other than 'heuristic'.""" + plan = ScenarioPlan( + scenario_id=uuid.uuid4().hex, + name="bad method", + store_id=1, + product_id=1, + run_id="abc", + horizon=7, + assumptions={}, + comparison={}, + method="not_heuristic", + ) + db_session.add(plan) + with pytest.raises(IntegrityError): + await db_session.commit() + await db_session.rollback() diff --git a/app/features/scenarios/tests/test_schemas.py b/app/features/scenarios/tests/test_schemas.py new file mode 100644 index 00000000..47339b0e --- /dev/null +++ b/app/features/scenarios/tests/test_schemas.py @@ -0,0 +1,79 @@ +"""Unit tests for the scenario request / response schemas. + +The critical case exercises the FastAPI ``validate_python`` path — calling +``model_validate`` on a dict with ISO-string dates — to prove the +``Field(strict=False)`` overrides on every ``date`` field hold. Without them +every HTTP caller would 422 (see ``docs/_base/SECURITY.md``). +""" + +from datetime import date + +import pytest +from pydantic import ValidationError + +from app.features.scenarios.schemas import ( + CreateScenarioRequest, + PriceAssumption, + ScenarioAssumptions, + SimulateScenarioRequest, +) + + +def test_simulate_request_accepts_iso_string_dates() -> None: + """A JSON-shaped dict with ISO-string dates validates (validate_python path).""" + request = SimulateScenarioRequest.model_validate( + { + "run_id": "abc123def456", + "horizon": 14, + "assumptions": { + "price": { + "change_pct": -0.15, + "start_date": "2026-06-01", + "end_date": "2026-06-14", + }, + "holiday": {"dates": ["2026-06-07", "2026-06-08"]}, + }, + } + ) + assert request.assumptions.price is not None + assert request.assumptions.price.start_date == date(2026, 6, 1) + assert request.assumptions.holiday is not None + assert request.assumptions.holiday.dates == [date(2026, 6, 7), date(2026, 6, 8)] + + +def test_simulate_request_defaults_to_empty_assumptions() -> None: + """Omitting ``assumptions`` yields an empty (no-change) ScenarioAssumptions.""" + request = SimulateScenarioRequest.model_validate({"run_id": "abc", "horizon": 7}) + assert isinstance(request.assumptions, ScenarioAssumptions) + assert request.assumptions.price is None + assert request.assumptions.promotion is None + + +def test_price_assumption_change_pct_bounds() -> None: + """change_pct outside [-0.9, 5.0] is rejected.""" + with pytest.raises(ValidationError): + PriceAssumption.model_validate( + {"change_pct": -1.5, "start_date": "2026-06-01", "end_date": "2026-06-14"} + ) + with pytest.raises(ValidationError): + PriceAssumption.model_validate( + {"change_pct": 9.0, "start_date": "2026-06-01", "end_date": "2026-06-14"} + ) + + +def test_simulate_request_horizon_bounds() -> None: + """horizon must be within 1..90.""" + with pytest.raises(ValidationError): + SimulateScenarioRequest.model_validate({"run_id": "abc", "horizon": 0}) + with pytest.raises(ValidationError): + SimulateScenarioRequest.model_validate({"run_id": "abc", "horizon": 200}) + + +def test_create_request_requires_name() -> None: + """CreateScenarioRequest requires a non-empty name.""" + with pytest.raises(ValidationError): + CreateScenarioRequest.model_validate({"name": "", "run_id": "abc", "horizon": 14}) + request = CreateScenarioRequest.model_validate( + {"name": "Summer discount", "run_id": "abc", "horizon": 14} + ) + assert request.name == "Summer discount" diff --git a/app/main.py b/app/main.py index 9aaa4882..bcc09d35 100644 --- a/app/main.py +++ b/app/main.py @@ -27,6 +27,7 @@ from app.features.ops.routes import router as ops_router from app.features.rag.routes import router as rag_router from app.features.registry.routes import router as registry_router +from app.features.scenarios.routes import router as scenarios_router from app.features.seeder.routes import router as seeder_router logger = get_logger(__name__) @@ -140,6 +141,7 @@ def create_app() -> FastAPI: app.include_router(backtesting_router) app.include_router(registry_router) app.include_router(rag_router) + app.include_router(scenarios_router) app.include_router(agents_router) app.include_router(agents_ws_router) app.include_router(seeder_router) diff --git a/docs/_base/API_CONTRACTS.md b/docs/_base/API_CONTRACTS.md index d46e99ea..0fa10d9a 100644 --- a/docs/_base/API_CONTRACTS.md +++ b/docs/_base/API_CONTRACTS.md @@ -22,6 +22,11 @@ All endpoints serve JSON; error responses use `application/problem+json` (RFC 78 | forecasting | POST | `/forecasting/train` | Train a model (naive / seasonal_naive / moving_average / lightgbm) | | forecasting | POST | `/forecasting/predict` | Generate horizon predictions from a trained model | | backtesting | POST | `/backtesting/run` | Time-series CV (rolling/expanding splits, MAE/sMAPE/WAPE/bias/stability) | +| scenarios | POST | `/scenarios/simulate` | Stateless what-if: load a baseline model, forecast, apply deterministic price/promotion/holiday/inventory/lifecycle factors, return a `ScenarioComparison` (`method="heuristic"`). Bogus `run_id` → RFC 7807 404 | +| scenarios | POST | `/scenarios` | Run a simulation and persist it as a named `scenario_plan` (raw assumptions + full comparison snapshot) | +| scenarios | GET | `/scenarios` | List saved scenario plans, newest first (`limit`/`offset`); `200` + empty list on an empty table | +| scenarios | GET | `/scenarios/{scenario_id}` | Saved plan + embedded comparison snapshot; `404` when missing | +| scenarios | DELETE | `/scenarios/{scenario_id}` | Delete a saved plan; `404` when missing | | registry | POST | `/registry/runs` | Create model run (pending) | | registry | GET | `/registry/runs` | List with filters + pagination + optional allow-listed `sort_by`/`sort_order` (created_at/model_type/status/store_id/product_id; unknown → default `created_at desc`) | | registry | GET | `/registry/runs/{run_id}` | Run details + JSONB metrics + runtime_info | diff --git a/docs/_base/DOMAIN_MODEL.md b/docs/_base/DOMAIN_MODEL.md index 25007301..733c9d8f 100644 --- a/docs/_base/DOMAIN_MODEL.md +++ b/docs/_base/DOMAIN_MODEL.md @@ -9,6 +9,7 @@ | Featuresets | Computed feature matrices (in-memory; not persisted) | Time-cutoff parameter — never reads beyond `cutoff_date` | | Forecasting | Trained model artifacts on disk (joblib `.pkl`) | Model interface in `examples/models/model_interface.md`; artifact_uri returned to caller | | Backtesting | Fold results, metrics (returned in response; persisted via Registry) | `SplitConfig` (expanding/sliding, gap, horizon) — `app/features/backtesting/splitter.py` | +| Scenarios | `scenario_plan` (saved what-if plans, JSONB assumptions + comparison) | `load_model_bundle` only (never a sibling `service.py`); deterministic `adjustments.py` post-forecast multiplier | | Registry | `model_run`, `run_alias`, `model_artifact` | SHA-256 hash on artifact_uri; status state machine | | RAG | `rag_source`, `rag_chunk` (with pgvector embedding column) | Content hash for idempotent indexing; embedding dimension fixed per provider | | Agents | `agent_session` (JSONB message_history) | Pydantic-validated tool args; HITL approval queue | @@ -42,6 +43,14 @@ - `store_id`, `product_id`, `date` must reference existing dimension rows. - Idempotent upsert via `ON CONFLICT (store_id, product_id, date) DO UPDATE` (`app/features/ingest/service.py`). +### `scenario_plan` (Scenarios) +- **Root:** `ScenarioPlan(scenario_id: str, name: str)` +- **JSONB fields:** `assumptions` (the raw `ScenarioAssumptions`), `comparison` (the full `ScenarioComparison` snapshot — stored so a reloaded plan re-renders without recomputation or the original artifact). +- **Invariants:** + - `method` is CHECK-constrained to `'heuristic'` — the MVP only ever produces a deterministic post-forecast multiplier, never a re-trained causal model. + - A scenario adjustment touches only horizon (future) points; it never reads or mutates the historical series (`app/features/scenarios/tests/test_leakage.py` is the spec). + - JSONB columns are persisted via `model_dump(mode="json")` so `date`/`datetime` serialise to ISO strings. + ## Key Invariants — NEVER violate 1. **Time safety in features.** `app/features/featuresets/` uses only data at or before `cutoff_date`. Lags via `shift(positive)`, rolling via `shift(1).rolling(...)`, all `groupby` entity-aware. The test `app/features/featuresets/tests/test_leakage.py` is the spec — it MUST keep passing. @@ -70,6 +79,9 @@ | `replenishment event` | One row in `replenishment_event` representing inbound stock at `(store, product, date)`; feature cadence is derived from event spacing | inbound order, restock (those would be different grains) | | `promotion (kind)` | One row in `promotion` with `kind ∈ {pct_off, bogo, bundle, markdown}`; features are one-hot per kind via `PromotionConfig.kinds_to_track` | discount, sale (kind is the discriminator, not "promotion" in the colloquial sense) | | `scenario` (seeder) | A YAML or in-code preset (`retail_standard`, `holiday_rush`, …) that wires `DimensionConfig` + `FactsConfig` | template, profile | +| `scenario plan` | A saved what-if analysis — a `scenario_plan` row pairing raw `ScenarioAssumptions` with a `ScenarioComparison` snapshot | seeder `scenario` (a different concept entirely) | +| `assumption` (what-if) | One future change a planner posits — a price change, promotion, holiday set, inventory cap, or lifecycle stage — fed to `POST /scenarios/simulate` | forecast input, feature | +| `applied factor` | The deterministic per-day multiplier `combined_daily_factor` derives from the assumptions; `1.0` means no change | weight, coefficient | ## Event Taxonomy @@ -92,6 +104,8 @@ rag_source ──owns──► rag_chunk (with pgvector embedding) agent_session ──owns──► message_history (JSONB) ──may-contain──► tool_call (pending approval) job ──may-reference──► model_run (for train/backtest jobs) + +scenario_plan ──built-from──► model artifact (a baseline run_id) ──embeds──► comparison snapshot (JSONB) ``` ## Glossary (cross-cutting) diff --git a/docs/_base/REPO_MAP_INDEX.md b/docs/_base/REPO_MAP_INDEX.md index 8158a010..e797bd92 100644 --- a/docs/_base/REPO_MAP_INDEX.md +++ b/docs/_base/REPO_MAP_INDEX.md @@ -31,7 +31,9 @@ ForecastLabAI is a portfolio-grade, single-host retail-demand-forecasting system | [`frontend/src/pages/explorer/job-detail.tsx`](../../frontend/src/pages/explorer/job-detail.tsx) | The job detail page — profile, params/result JSON, error details, linked run, cancel action, live polling | Investigating a single job | | [`frontend/src/pages/explorer/run-compare.tsx`](../../frontend/src/pages/explorer/run-compare.tsx) | The run-comparison page — two run pickers, side-by-side profile, config_diff, metrics_diff with delta indicators; deep-linkable via `?a=&b=` | Comparing two model runs | | [`frontend/src/pages/visualize/demand.tsx`](../../frontend/src/pages/visualize/demand.tsx) | The Demand Planner page — completed `predict` jobs rolled into a multi-SKU table (tomorrow/next-week/next-month demand + inventory requirement), lead-time selector, single-SKU drill-in | Answering "how much will this SKU sell, and do I have enough stock?" | -| [`alembic/versions/`](../../alembic/versions/) | Six migrations through `d6e0f2g3h456_create_agent_session_table.py` | DB-schema questions, migration drift | +| [`app/features/scenarios/`](../../app/features/scenarios/) | Scenario Simulation slice — `POST /scenarios/simulate` (stateless what-if) + `scenario_plan` CRUD; pure `adjustments.py` deterministic factors, never imports a sibling `service.py` | What-If planning, baseline-vs-scenario comparisons | +| [`frontend/src/pages/visualize/planner.tsx`](../../frontend/src/pages/visualize/planner.tsx) | The What-If Planner page — pick a baseline predict job, define price/promotion/holiday/inventory/lifecycle assumptions, run a simulation, save / reload / delete named plans | Answering "what if we discount this SKU 15% next week?" | +| [`alembic/versions/`](../../alembic/versions/) | Migrations through `43e35957a248_create_scenario_plan_table.py` | DB-schema questions, migration drift | | [`docs/ARCHITECTURE.md`](../ARCHITECTURE.md) | Phase-by-phase architecture narrative | High-level component reasoning | | [`docs/PHASE-index.md`](../PHASE-index.md) | Index of all 11 phase docs | Locating per-phase deep-dive | | [`docs/PHASE/*.md`](../PHASE/) | Per-phase implementation reference | Slice-specific deep dives | diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index be5b288f..6619ead2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -24,6 +24,7 @@ const JobDetailPage = lazy(() => import('@/pages/explorer/job-detail')) const ForecastPage = lazy(() => import('@/pages/visualize/forecast')) const BacktestPage = lazy(() => import('@/pages/visualize/backtest')) const DemandPlannerPage = lazy(() => import('@/pages/visualize/demand')) +const WhatIfPlannerPage = lazy(() => import('@/pages/visualize/planner')) const ChatPage = lazy(() => import('@/pages/chat')) const KnowledgePage = lazy(() => import('@/pages/knowledge')) const GuidePage = lazy(() => import('@/pages/guide')) @@ -168,6 +169,14 @@ function App() { } /> + }> + + + } + /> + api('/scenarios/simulate', { method: 'POST', body: data }), + }) +} + +/** List saved scenario plans, newest first. */ +export function useScenarios(enabled = true) { + return useQuery({ + queryKey: ['scenarios'], + queryFn: () => api('/scenarios'), + enabled, + }) +} + +/** Fetch one saved plan, including its embedded comparison snapshot. */ +export function useScenario(scenarioId: string, enabled = true) { + return useQuery({ + queryKey: ['scenarios', scenarioId], + queryFn: () => api(`/scenarios/${scenarioId}`), + enabled: enabled && !!scenarioId, + }) +} + +/** Persist a scenario plan; invalidates the saved-plans list on success. */ +export function useCreateScenario() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (data: CreateScenarioRequest) => + api('/scenarios', { method: 'POST', body: data }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['scenarios'] }) + }, + }) +} + +/** Delete a saved scenario plan; invalidates the saved-plans list on success. */ +export function useDeleteScenario() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (scenarioId: string) => + api(`/scenarios/${scenarioId}`, { method: 'DELETE' }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['scenarios'] }) + }, + }) +} diff --git a/frontend/src/lib/constants.ts b/frontend/src/lib/constants.ts index 50306a81..64f60654 100644 --- a/frontend/src/lib/constants.ts +++ b/frontend/src/lib/constants.ts @@ -23,6 +23,7 @@ export const ROUTES = { FORECAST: '/visualize/forecast', BACKTEST: '/visualize/backtest', DEMAND: '/visualize/demand', + PLANNER: '/visualize/planner', }, KNOWLEDGE: '/knowledge', CHAT: '/chat', @@ -49,6 +50,7 @@ export const NAV_ITEMS = [ label: 'Visualize', items: [ { label: 'Demand Planner', href: ROUTES.VISUALIZE.DEMAND }, + { label: 'What-If Planner', href: ROUTES.VISUALIZE.PLANNER }, { label: 'Forecast', href: ROUTES.VISUALIZE.FORECAST }, { label: 'Backtest Results', href: ROUTES.VISUALIZE.BACKTEST }, ], diff --git a/frontend/src/lib/scenario-utils.test.ts b/frontend/src/lib/scenario-utils.test.ts new file mode 100644 index 00000000..e5f5cdf5 --- /dev/null +++ b/frontend/src/lib/scenario-utils.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest' +import { + coverageLabel, + coverageVariant, + formatDelta, + mergeComparisonSeries, + summariseAssumptions, +} from './scenario-utils' +import type { ScenarioAssumptions, ScenarioPoint } from '@/types/api' + +function makePoint(date: string, baseline: number, scenario: number): ScenarioPoint { + return { + date, + baseline, + scenario, + delta: scenario - baseline, + applied_factor: baseline === 0 ? 1 : scenario / baseline, + } +} + +describe('mergeComparisonSeries', () => { + it('flattens points into date / baseline / scenario rows', () => { + const rows = mergeComparisonSeries([makePoint('2026-07-01', 10, 12)]) + expect(rows).toEqual([{ date: '2026-07-01', baseline: 10, scenario: 12 }]) + }) + + it('returns an empty array for no points', () => { + expect(mergeComparisonSeries([])).toEqual([]) + }) +}) + +describe('formatDelta', () => { + it('prefixes a plus sign for positive values', () => { + expect(formatDelta(12.34)).toBe('+12.3') + }) + + it('keeps the minus sign for negative values', () => { + expect(formatDelta(-4.5)).toBe('-4.5') + }) + + it('formats zero without a sign', () => { + expect(formatDelta(0)).toBe('0.0') + }) + + it('honours the decimals argument', () => { + expect(formatDelta(3, 0)).toBe('+3') + }) +}) + +describe('coverageLabel / coverageVariant', () => { + it('maps every verdict to a label and a badge variant', () => { + expect(coverageLabel('covered')).toBe('Covered') + expect(coverageLabel('at_risk')).toBe('At risk') + expect(coverageLabel('stockout')).toBe('Stockout') + expect(coverageLabel('unknown')).toBe('Unknown') + expect(coverageVariant('covered')).toBe('success') + expect(coverageVariant('at_risk')).toBe('warning') + expect(coverageVariant('stockout')).toBe('error') + expect(coverageVariant('unknown')).toBe('default') + }) +}) + +describe('summariseAssumptions', () => { + it('returns a baseline-only line for empty assumptions', () => { + expect(summariseAssumptions({})).toEqual(['No assumptions — baseline only']) + }) + + it('summarises a price cut with sign-aware wording', () => { + const assumptions: ScenarioAssumptions = { + price: { change_pct: -0.15, start_date: '2026-07-01', end_date: '2026-07-14' }, + } + const [line] = summariseAssumptions(assumptions) + expect(line).toContain('Price cut of 15%') + }) + + it('lists every supplied assumption', () => { + const assumptions: ScenarioAssumptions = { + price: { change_pct: 0.1, start_date: '2026-07-01', end_date: '2026-07-07' }, + promotion: { kind: 'bogo', start_date: '2026-07-02', end_date: '2026-07-05' }, + holiday: { dates: ['2026-07-04'] }, + inventory: { on_hand_units: 500 }, + lifecycle: { stage: 'growth' }, + } + const lines = summariseAssumptions(assumptions) + expect(lines).toHaveLength(5) + expect(lines[0]).toContain('Price increase of 10%') + expect(lines[1]).toContain('bogo promotion') + expect(lines[2]).toContain('1 holiday/event day') + expect(lines[3]).toContain('500 units') + expect(lines[4]).toContain('growth') + }) +}) diff --git a/frontend/src/lib/scenario-utils.ts b/frontend/src/lib/scenario-utils.ts new file mode 100644 index 00000000..3bb6e880 --- /dev/null +++ b/frontend/src/lib/scenario-utils.ts @@ -0,0 +1,107 @@ +/** + * Pure helpers for the What-If Planner page. + * + * No React, no I/O — every function here is unit-tested in + * scenario-utils.test.ts. The planner composes these to turn a + * `ScenarioComparison` into chart rows, a delta table, and readable summaries. + */ +import type { CsvColumn } from '@/lib/csv-export' +import type { CoverageVerdict, ScenarioAssumptions, ScenarioPoint } from '@/types/api' + +/** One charted day: a date plus the baseline and scenario demand values. */ +export interface ComparisonChartRow { + date: string + baseline: number + scenario: number + // Index signature so the row is assignable to TimeSeriesChart's data prop. + [key: string]: string | number | null | undefined +} + +/** Flatten comparison points into the two-series rows TimeSeriesChart renders. */ +export function mergeComparisonSeries(points: ScenarioPoint[]): ComparisonChartRow[] { + return points.map((point) => ({ + date: point.date, + baseline: point.baseline, + scenario: point.scenario, + })) +} + +/** Format a number with an explicit sign (+1.5 / -2.0 / 0.0). */ +export function formatDelta(value: number, decimals = 1): string { + const sign = value > 0 ? '+' : '' + return `${sign}${value.toFixed(decimals)}` +} + +/** Human label for a coverage verdict. */ +export function coverageLabel(verdict: CoverageVerdict): string { + switch (verdict) { + case 'covered': + return 'Covered' + case 'at_risk': + return 'At risk' + case 'stockout': + return 'Stockout' + default: + return 'Unknown' + } +} + +/** StatusBadge variant for a coverage verdict. */ +export function coverageVariant( + verdict: CoverageVerdict, +): 'success' | 'warning' | 'error' | 'default' { + switch (verdict) { + case 'covered': + return 'success' + case 'at_risk': + return 'warning' + case 'stockout': + return 'error' + default: + return 'default' + } +} + +/** CSV columns for the per-day delta-table export. */ +export const deltaCsvColumns: CsvColumn[] = [ + { key: 'date', header: 'Date' }, + { key: 'baseline', header: 'Baseline' }, + { key: 'scenario', header: 'Scenario' }, + { key: 'delta', header: 'Delta' }, + { key: 'applied_factor', header: 'Factor' }, +] + +/** Render the active what-if assumptions as human-readable bullet lines. */ +export function summariseAssumptions(assumptions: ScenarioAssumptions): string[] { + const lines: string[] = [] + + if (assumptions.price) { + const pct = Math.round(assumptions.price.change_pct * 100) + const verb = pct < 0 ? 'cut' : 'increase' + lines.push( + `Price ${verb} of ${Math.abs(pct)}% from ${assumptions.price.start_date} ` + + `to ${assumptions.price.end_date}`, + ) + } + if (assumptions.promotion) { + lines.push( + `${assumptions.promotion.kind} promotion from ${assumptions.promotion.start_date} ` + + `to ${assumptions.promotion.end_date}`, + ) + } + if (assumptions.holiday && assumptions.holiday.dates.length > 0) { + const count = assumptions.holiday.dates.length + lines.push(`${count} holiday/event day${count === 1 ? '' : 's'}`) + } + if (assumptions.inventory) { + lines.push(`On-hand stock of ${assumptions.inventory.on_hand_units} units`) + } + if (assumptions.lifecycle) { + lines.push(`Lifecycle stage forced to "${assumptions.lifecycle.stage}"`) + } + + if (lines.length === 0) { + lines.push('No assumptions — baseline only') + } + return lines +} diff --git a/frontend/src/pages/visualize/planner.tsx b/frontend/src/pages/visualize/planner.tsx new file mode 100644 index 00000000..ed1c2515 --- /dev/null +++ b/frontend/src/pages/visualize/planner.tsx @@ -0,0 +1,632 @@ +import { useState } from 'react' +import { AlertTriangle, Download, Loader2, Play, Save, Trash2 } from 'lucide-react' +import { useJob } from '@/hooks/use-jobs' +import { + useCreateScenario, + useDeleteScenario, + useScenario, + useScenarios, + useSimulateScenario, +} from '@/hooks/use-scenarios' +import { TimeSeriesChart } from '@/components/charts/time-series-chart' +import { JobPicker } from '@/components/common/job-picker' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Checkbox } from '@/components/ui/checkbox' +import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { StatusBadge } from '@/components/common/status-badge' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { downloadCsv, toCsv } from '@/lib/csv-export' +import { formatCurrency, formatNumber, getErrorMessage } from '@/lib/api' +import { + coverageLabel, + coverageVariant, + deltaCsvColumns, + formatDelta, + mergeComparisonSeries, +} from '@/lib/scenario-utils' +import type { + PromotionAssumption, + ScenarioAssumptions, + ScenarioComparison, +} from '@/types/api' + +/** Horizon presets (days) for a simulation run. */ +const HORIZON_OPTIONS = [7, 14, 30, 60, 90] +/** Promotion mechanics offered on the assumption form. */ +const PROMOTION_KINDS: PromotionAssumption['kind'][] = ['pct_off', 'bogo', 'bundle', 'markdown'] +/** Lifecycle stages offered on the assumption form. */ +const LIFECYCLE_STAGES = ['launch', 'growth', 'maturity', 'decline'] as const + +/** A headline metric tile for the results panel. */ +function KpiTile({ label, value, hint }: { label: string; value: string; hint?: string }) { + return ( +
+

{label}

+

{value}

+ {hint &&

{hint}

} +
+ ) +} + +export default function WhatIfPlannerPage() { + // -- Baseline selection ------------------------------------------------ + const [selectedJobId, setSelectedJobId] = useState('') + const [horizon, setHorizon] = useState(14) + const { data: job } = useJob(selectedJobId, !!selectedJobId) + // A predict job's params.run_id is the baseline model artifact key. + const baselineRunId = typeof job?.params?.run_id === 'string' ? job.params.run_id : null + + // -- Assumption form state --------------------------------------------- + const [priceEnabled, setPriceEnabled] = useState(false) + const [priceChangePct, setPriceChangePct] = useState(-15) + const [priceStart, setPriceStart] = useState('') + const [priceEnd, setPriceEnd] = useState('') + + const [promoEnabled, setPromoEnabled] = useState(false) + const [promoKind, setPromoKind] = useState('pct_off') + const [promoStart, setPromoStart] = useState('') + const [promoEnd, setPromoEnd] = useState('') + + const [holidayEnabled, setHolidayEnabled] = useState(false) + const [holidayDates, setHolidayDates] = useState('') + + const [inventoryEnabled, setInventoryEnabled] = useState(false) + const [onHandUnits, setOnHandUnits] = useState(0) + + const [lifecycleEnabled, setLifecycleEnabled] = useState(false) + const [lifecycleStage, setLifecycleStage] = + useState<(typeof LIFECYCLE_STAGES)[number]>('maturity') + + // -- Results / persistence state --------------------------------------- + const [simulated, setSimulated] = useState(null) + const [planName, setPlanName] = useState('') + const [runError, setRunError] = useState(null) + const [reloadId, setReloadId] = useState('') + + const simulate = useSimulateScenario() + const createScenario = useCreateScenario() + const deleteScenario = useDeleteScenario() + const scenariosQuery = useScenarios() + const reloadedPlan = useScenario(reloadId, !!reloadId) + + // The comparison on screen is either a fresh simulation result or, when a + // saved plan has been reloaded, that plan's embedded snapshot. Deriving it + // (rather than copying into state inside an effect) keeps the render pure. + const comparison: ScenarioComparison | null = reloadId + ? (reloadedPlan.data?.comparison ?? null) + : simulated + + /** Assemble the ScenarioAssumptions payload from the enabled form sections. */ + function buildAssumptions(): ScenarioAssumptions { + const assumptions: ScenarioAssumptions = {} + if (priceEnabled) { + assumptions.price = { + change_pct: priceChangePct / 100, + start_date: priceStart, + end_date: priceEnd, + } + } + if (promoEnabled) { + assumptions.promotion = { kind: promoKind, start_date: promoStart, end_date: promoEnd } + } + if (holidayEnabled) { + const dates = holidayDates + .split(',') + .map((value) => value.trim()) + .filter(Boolean) + if (dates.length > 0) assumptions.holiday = { dates } + } + if (inventoryEnabled) { + assumptions.inventory = { on_hand_units: onHandUnits } + } + if (lifecycleEnabled) { + assumptions.lifecycle = { stage: lifecycleStage } + } + return assumptions + } + + async function handleRun() { + if (!baselineRunId) return + setRunError(null) + setReloadId('') + try { + const result = await simulate.mutateAsync({ + run_id: baselineRunId, + horizon, + assumptions: buildAssumptions(), + }) + setSimulated(result) + } catch (caught) { + setRunError(getErrorMessage(caught)) + setSimulated(null) + } + } + + async function handleSave() { + if (!baselineRunId || !planName.trim()) return + setRunError(null) + try { + await createScenario.mutateAsync({ + name: planName.trim(), + run_id: baselineRunId, + horizon, + assumptions: buildAssumptions(), + }) + setPlanName('') + } catch (caught) { + setRunError(getErrorMessage(caught)) + } + } + + function handleExport() { + if (!comparison) return + downloadCsv('scenario-deltas.csv', toCsv(comparison.points, deltaCsvColumns)) + } + + return ( +
+
+

What-If Planner

+

+ Take an existing forecast, apply future assumptions — a price change, a promotion, + holidays, a stock cap — and see the demand and revenue impact before committing. +

+
+ + {/* Heuristic disclaimer — always visible, prominent. */} + + + +
+

Heuristic estimates — not a causal model

+

+ Scenario results apply fixed, deterministic adjustment factors to a baseline + forecast. Treat the demand and revenue deltas as directional planning signals, + not precise predictions. +

+
+
+
+ + {/* Baseline picker */} + + + 1. Pick a baseline + + Choose a completed prediction job — its model is the baseline this scenario adjusts. + + + + +
+
+ Horizon + +
+
+ {selectedJobId && !baselineRunId && ( +

+ The selected job has no model artifact — pick a completed predict job. +

+ )} +
+
+ + {/* Assumptions form */} + + + 2. Define assumptions + + Every assumption is optional. Leave them all off for a no-change baseline. + + + + {/* Price */} +
+ + {priceEnabled && ( +
+
+ Change % + setPriceChangePct(Number(event.target.value))} + /> +
+
+ From + setPriceStart(event.target.value)} + /> +
+
+ To + setPriceEnd(event.target.value)} + /> +
+
+ )} +
+ + {/* Promotion */} +
+ + {promoEnabled && ( +
+
+ Kind + +
+
+ From + setPromoStart(event.target.value)} + /> +
+
+ To + setPromoEnd(event.target.value)} + /> +
+
+ )} +
+ + {/* Holiday */} +
+ + {holidayEnabled && ( +
+ + Comma-separated dates (YYYY-MM-DD) + + setHolidayDates(event.target.value)} + /> +
+ )} +
+ + {/* Inventory */} +
+ + {inventoryEnabled && ( +
+ On-hand units + setOnHandUnits(Number(event.target.value))} + /> +
+ )} +
+ + {/* Lifecycle */} +
+ + {lifecycleEnabled && ( +
+ Stage + +
+ )} +
+ +
+ + {runError &&

{runError}

} +
+
+
+ + {/* Results */} + {comparison && ( + <> + + + Scenario impact + + {comparison.model_type} model · store {comparison.store_id} · product{' '} + {comparison.product_id} · {comparison.horizon}-day horizon + + + +
+ + + +
+

Coverage

+
+ + {coverageLabel(comparison.coverage_verdict)} + +
+
+
+ + + +

+ {comparison.disclaimer} +

+
+
+ + {/* Per-day delta table */} + + +
+
+ Per-day deltas + Daily baseline, scenario, and applied factor. +
+ +
+
+ + + + + Date + Baseline + Scenario + Delta + Factor + + + + {comparison.points.map((point) => ( + + {point.date} + {formatNumber(point.baseline, 1)} + {formatNumber(point.scenario, 1)} + {formatDelta(point.delta)} + + {point.applied_factor.toFixed(2)}× + + + ))} + +
+
+
+ + {/* Save as plan */} + + + Save this scenario + + Persist the assumptions and the comparison snapshot as a named plan. + + + +
+
+ Plan name + setPlanName(event.target.value)} + /> +
+ +
+
+
+ + )} + + {/* Saved plans */} + + + Saved plans + + Reload a saved plan to re-render its comparison, or delete one. + + + + {scenariosQuery.data && scenariosQuery.data.scenarios.length > 0 ? ( + + + + Name + Units delta + Revenue delta + Actions + + + + {scenariosQuery.data.scenarios.map((plan) => ( + + {plan.name} + {formatDelta(plan.units_delta)} + + {formatCurrency(plan.revenue_delta)} + + +
+ + +
+
+
+ ))} +
+
+ ) : ( +

+ No saved plans yet. Run a simulation and save it above. +

+ )} +
+
+
+ ) +} diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index b9276cde..163f6c84 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -702,3 +702,124 @@ export interface ModelHealthResponse { total_evaluated: number generated_at: string } + +// ── Scenario Simulation / What-If Planner ── + +// A relative price change over a future date window. +export interface PriceAssumption { + change_pct: number + start_date: string + end_date: string +} + +// A promotion of a given kind running over a future date window. +export interface PromotionAssumption { + kind: 'pct_off' | 'bogo' | 'bundle' | 'markdown' + start_date: string + end_date: string +} + +// Explicit holiday / event days that lift demand. +export interface HolidayAssumption { + dates: string[] +} + +// On-hand stock used only to derive a coverage verdict. +export interface InventoryAssumption { + on_hand_units: number +} + +// A forced product lifecycle stage for the horizon. +export interface LifecycleAssumption { + stage: 'launch' | 'growth' | 'maturity' | 'decline' +} + +// The full set of optional what-if assumptions. +export interface ScenarioAssumptions { + price?: PriceAssumption | null + promotion?: PromotionAssumption | null + holiday?: HolidayAssumption | null + inventory?: InventoryAssumption | null + lifecycle?: LifecycleAssumption | null +} + +// Request body for POST /scenarios/simulate. +export interface SimulateScenarioRequest { + run_id: string + horizon: number + assumptions: ScenarioAssumptions + name?: string | null +} + +// Request body for POST /scenarios. +export interface CreateScenarioRequest { + name: string + run_id: string + horizon: number + assumptions: ScenarioAssumptions +} + +// Whether projected demand is covered by on-hand stock. +export type CoverageVerdict = 'covered' | 'at_risk' | 'stockout' | 'unknown' + +// One horizon day: baseline vs. scenario demand and the factor applied. +export interface ScenarioPoint { + date: string + baseline: number + scenario: number + delta: number + applied_factor: number +} + +// A full baseline-vs-scenario comparison — POST /scenarios/simulate. +export interface ScenarioComparison { + store_id: number + product_id: number + model_type: string + horizon: number + points: ScenarioPoint[] + baseline_total_units: number + scenario_total_units: number + units_delta: number + units_delta_pct: number + unit_price_used: number + baseline_revenue: number + scenario_revenue: number + revenue_delta: number + coverage_verdict: CoverageVerdict + method: 'heuristic' + disclaimer: string + generated_at: string +} + +// A persisted scenario plan with its embedded comparison snapshot. +export interface ScenarioPlanResponse { + scenario_id: string + name: string + store_id: number + product_id: number + run_id: string + horizon: number + method: string + created_at: string + assumptions: ScenarioAssumptions + comparison: ScenarioComparison +} + +// A compact row in the saved-plans list. +export interface ScenarioListItem { + scenario_id: string + name: string + store_id: number + product_id: number + horizon: number + units_delta: number + revenue_delta: number + created_at: string +} + +// A page of saved scenario plans — GET /scenarios. +export interface ScenarioListResponse { + scenarios: ScenarioListItem[] + total: number +}