|
| 1 | +# feat(forecast): add feature frame v2 |
| 2 | + |
| 3 | +Tracking issue: **#299** (under Forecast Intelligence roadmap epic **#295**). |
| 4 | +PRP: `PRPs/PRP-35-forecast-intelligence-A-feature-frame-v2.md`. |
| 5 | + |
| 6 | +## Summary |
| 7 | + |
| 8 | +Lands the **V2 feature-frame contract** as an **additive, opt-in** surface |
| 9 | +alongside the frozen V1 contract: |
| 10 | + |
| 11 | +- **Shared layer** — V2 column manifest (38 default / 53 max columns across 11 |
| 12 | + `FeatureGroup`s), `V2HistoricalSidecar` / `V2FutureSidecar` data carriers, |
| 13 | + and `build_historical_feature_rows_v2` / `build_future_feature_rows_v2` |
| 14 | + pure row builders. `app/shared/feature_frames/` stays leaf-level. |
| 15 | +- **Training path** — `POST /forecasting/train` accepts optional |
| 16 | + `feature_frame_version: int = 1` and `feature_groups: list[str] | None = None`. |
| 17 | + V2 bundles persist `feature_frame_version`, `feature_columns`, |
| 18 | + `feature_groups`, `feature_safety_classes`, and `feature_pinned_constants` |
| 19 | + in bundle metadata. |
| 20 | +- **Scenarios path** — `POST /scenarios/simulate` reads `feature_frame_version` |
| 21 | + from the loaded bundle metadata and dispatches V1 vs V2 future-frame |
| 22 | + assembly transparently. |
| 23 | +- **LOAD-BEARING leakage specs** — three new specs land alongside the V1 |
| 24 | + spec; never to be weakened: |
| 25 | + - `app/shared/feature_frames/tests/test_leakage_v2.py` |
| 26 | + - `app/features/forecasting/tests/test_regression_features_v2_leakage.py` |
| 27 | + - `app/features/scenarios/tests/test_future_frame_v2_leakage.py` |
| 28 | + |
| 29 | +## V1 compatibility (back-compat invariant) |
| 30 | + |
| 31 | +- Every V1 export keeps its current signature, return type, and behaviour. |
| 32 | +- The load-bearing V1 leakage spec |
| 33 | + (`app/shared/feature_frames/tests/test_leakage.py`) and 22 sibling V1 |
| 34 | + contract tests remain green **without modification**. |
| 35 | +- V1 bundles trained before this PR load, predict, scenario-simulate, and |
| 36 | + backtest unchanged. |
| 37 | +- `feature_frame_version=1` is the default everywhere; legacy bundles that |
| 38 | + predate the metadata field are treated as V1 via |
| 39 | + `bundle.metadata.get("feature_frame_version", 1)`. |
| 40 | +- `feature_frame_version` lives on `TrainRequest`, **not** on |
| 41 | + `ModelConfigBase` — adding it to the config would mutate every existing V1 |
| 42 | + `config_hash()` and orphan registry rows / aliases. Persisted to bundle |
| 43 | + metadata instead. |
| 44 | + |
| 45 | +## V2 opt-in behaviour |
| 46 | + |
| 47 | +- A `TrainRequest` with `feature_frame_version=2` (optionally `feature_groups=[…]`) |
| 48 | + triggers the V2 path; otherwise V1 runs unchanged. |
| 49 | +- Validator gates: |
| 50 | + - V1 + `feature_groups` supplied → 422. |
| 51 | + - V2 + unknown `FeatureGroup` name → 422. |
| 52 | +- Default V2 groups: `TARGET_HISTORY`, `CALENDAR`, `ROLLING`, `TREND`, |
| 53 | + `PRICE_PROMO`, `LIFECYCLE` (38 columns). Phase-2 sidecar groups |
| 54 | + (`INVENTORY`, `REPLENISHMENT`, `RETURNS`, `EXOGENOUS_WEATHER`, |
| 55 | + `EXOGENOUS_MACRO`) are off by default so the MVP stays green on smaller |
| 56 | + seeded DBs (max 53 columns when all enabled). |
| 57 | +- Pinned V2 constants: `EXOGENOUS_LAGS_V2=(1,7,14,28,56,364)`, |
| 58 | + `ROLLING_WINDOWS_V2=(7,28,90)`, `TREND_WINDOWS_V2=(30,90)`, |
| 59 | + `HISTORY_TAIL_DAYS_V2=400`. |
| 60 | + |
| 61 | +## Validation |
| 62 | + |
| 63 | +All four mandatory gates green locally on `Python 3.12`: |
| 64 | + |
| 65 | +``` |
| 66 | +✅ uv run ruff check . All checks passed |
| 67 | +✅ uv run ruff format --check . 327 files already formatted |
| 68 | +✅ uv run mypy app/ 0 PRP-35 errors (3 pre-existing xgboost noise on dev) |
| 69 | +✅ uv run pyright app/ 0 PRP-35 errors (8 pre-existing optional-extra noise on dev) |
| 70 | +✅ uv run pytest -m "not integration" 1480 passed, 12 skipped, 264 deselected |
| 71 | +``` |
| 72 | + |
| 73 | +40 V2 leakage tests across 3 LOAD-BEARING files all green; 23 V1 contract / |
| 74 | +leakage tests byte-stable. |
| 75 | + |
| 76 | +The 3 mypy + 8 pyright pre-existing errors stem from optional `lightgbm` / |
| 77 | +`xgboost` extras and are unrelated to PRP-35; CI runs `--all-extras` and won't |
| 78 | +see them. |
| 79 | + |
| 80 | +## No Alembic migration |
| 81 | + |
| 82 | +V2 reads only existing tables (`inventory_snapshot_daily`, |
| 83 | +`replenishment_event`, `sales_returns`, `exogenous_signal`, `promotion`, |
| 84 | +`product`) and writes nothing to the DB. `alembic heads` unchanged at |
| 85 | +`c1d2e3f40512`. |
| 86 | + |
| 87 | +## Deferred: V2 backtesting dispatch — tracked in #299 |
| 88 | + |
| 89 | +PRP-35 lands V2 **training + scenarios + shared builders**. **Backtesting V2 |
| 90 | +dispatch is deferred** and explicitly tracked in the |
| 91 | +"Deferred follow-up: V2 backtesting dispatch" section of **#299**. |
| 92 | + |
| 93 | +PRP-35 Task 13 reads *"READ `feature_frame_version` from the fitted bundle |
| 94 | +BEFORE the fold loop"*, but |
| 95 | +`app/features/backtesting/service.py:_run_model_backtest` trains fresh per |
| 96 | +fold from `BacktestConfig.model_config_main` and **does not load a fitted |
| 97 | +bundle**. The correct opt-in surface is a request-time field on |
| 98 | +`BacktestConfig` itself — a re-design Task 13 did not spec. |
| 99 | + |
| 100 | +**This PR does NOT claim completion of PRP-35 Tasks 13 or 18.** V1 |
| 101 | +backtesting is unchanged; a V2-trained bundle still trains and |
| 102 | +scenario-simulates correctly. Only `/backtesting/run` remains V1-only until |
| 103 | +the follow-up under #299 lands. Integration tests (PRP-35 Tasks 15 + 16) and |
| 104 | +the PHASE/3 + PHASE/4 doc edits (Task 21) are also deferred there. |
| 105 | + |
| 106 | +## qwen3 stash status |
| 107 | + |
| 108 | +The session's `stash@{0}` ("local qwen3 rag demo changes before prp-35", |
| 109 | +`app/features/rag/models.py` +7/-2) is **not applied, not popped, not |
| 110 | +dropped**. The decision on it (write a real |
| 111 | +`INITIAL-rag-embedding-provider-pluggability.md` doc vs. add to |
| 112 | +`.git/info/exclude`) is carryover work, untouched by this PR. |
| 113 | + |
| 114 | +## Files changed |
| 115 | + |
| 116 | +``` |
| 117 | + M app/features/forecasting/routes.py (+2) |
| 118 | + M app/features/forecasting/schemas.py (+70) |
| 119 | + M app/features/forecasting/service.py (+318) |
| 120 | + M app/features/scenarios/feature_frame.py (+193) |
| 121 | + M app/features/scenarios/service.py (+25) |
| 122 | + M app/shared/feature_frames/__init__.py (+79) |
| 123 | + M docs/optional-features/10-baseforecaster-feature-contract.md (+40) |
| 124 | + A app/shared/feature_frames/contract_v2.py |
| 125 | + A app/shared/feature_frames/rows_v2.py |
| 126 | + A app/shared/feature_frames/sidecar.py |
| 127 | + A app/shared/feature_frames/tests/test_contract_v2.py |
| 128 | + A app/shared/feature_frames/tests/test_leakage_v2.py |
| 129 | + A app/features/forecasting/v2_loaders.py |
| 130 | + A app/features/forecasting/tests/test_regression_features_v2_leakage.py |
| 131 | + A app/features/scenarios/tests/test_future_frame_v2_leakage.py |
| 132 | + A examples/forecasting/feature_frame_v2_preview.py |
| 133 | + A PR-BODY-DRAFT.md |
| 134 | +``` |
| 135 | + |
| 136 | +## Test plan |
| 137 | + |
| 138 | +- [ ] CI green on all five gates (ruff / mypy / pyright / pytest / migration-check). |
| 139 | +- [ ] Verify `/forecasting/train` accepts `feature_frame_version=2` with default groups. |
| 140 | +- [ ] Verify `/forecasting/train` accepts `feature_frame_version=2` with opt-in |
| 141 | + Phase-2 group (e.g. `INVENTORY`) on a seeded DB carrying inventory rows. |
| 142 | +- [ ] Verify `/scenarios/simulate` against a V2-trained bundle produces a |
| 143 | + `model_exogenous` re-forecast (V2 future-frame assembly via bundle metadata). |
| 144 | +- [ ] Verify a V1 bundle trained before this PR still loads, predicts, and |
| 145 | + scenario-simulates unchanged. |
| 146 | +- [ ] Verify `/backtesting/run` against a V2-trained bundle remains V1-only |
| 147 | + (no V2 dispatch on the fold loop) — documented deferral above. |
0 commit comments