Skip to content

Commit f2bf7c8

Browse files
authored
Merge pull request #300 from w7-mgfcode/feat/forecast-feature-frame-v2
feat(forecast): add feature frame v2
2 parents 26a105a + 4cbcdf4 commit f2bf7c8

17 files changed

Lines changed: 3736 additions & 27 deletions

PR-BODY-DRAFT.md

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
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.

app/features/forecasting/routes.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ async def train_model(
103103
train_start_date=request.train_start_date,
104104
train_end_date=request.train_end_date,
105105
config=request.config,
106+
feature_frame_version=request.feature_frame_version,
107+
feature_groups=request.feature_groups,
106108
)
107109

108110
logger.info(

app/features/forecasting/schemas.py

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313
from enum import Enum
1414
from typing import Literal
1515

16-
from pydantic import BaseModel, ConfigDict, Field, field_validator
16+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
17+
18+
from app.shared.feature_frames import FeatureGroup
1719

1820
# =============================================================================
1921
# Model Configuration Schemas
@@ -312,6 +314,30 @@ class TrainRequest(BaseModel):
312314
description="End date of training period (inclusive)",
313315
)
314316
config: ModelConfig
317+
# PRP-35: opt-in to the V2 feature contract (richer, leakage-safe). V1
318+
# remains the default and the back-compat path; V2 callers also set
319+
# ``feature_groups`` to pick the enabled :class:`FeatureGroup` subset.
320+
# NOTE: these fields live on ``TrainRequest``, NOT on ``ModelConfigBase`` —
321+
# adding them to the config would mutate every existing ``config_hash()``
322+
# value, orphaning every registry row and alias. The resolved version is
323+
# persisted into bundle metadata instead.
324+
feature_frame_version: int = Field(
325+
default=1,
326+
ge=1,
327+
le=2,
328+
description=(
329+
"Feature contract version. 1 = V1 (default, 14 columns, back-compat); "
330+
"2 = V2 (richer manifest, opt-in)."
331+
),
332+
)
333+
feature_groups: list[str] | None = Field(
334+
default=None,
335+
description=(
336+
"V2 only: optional list of FeatureGroup names to enable "
337+
"(None → DEFAULT_V2_GROUPS). MUST be None / omitted when "
338+
"feature_frame_version=1 (422 otherwise)."
339+
),
340+
)
315341

316342
@field_validator("train_end_date")
317343
@classmethod
@@ -323,6 +349,24 @@ def validate_date_range(cls, v: date_type, info: object) -> date_type:
323349
raise ValueError("train_end_date must be after train_start_date")
324350
return v
325351

352+
@model_validator(mode="after")
353+
def validate_feature_frame_version_and_groups(self) -> TrainRequest:
354+
"""Reject ``feature_groups`` when V1 and unknown group names when V2."""
355+
if self.feature_frame_version == 1 and self.feature_groups is not None:
356+
raise ValueError(
357+
"feature_groups is only valid when feature_frame_version=2; "
358+
"omit it for V1 training."
359+
)
360+
if self.feature_frame_version == 2 and self.feature_groups is not None:
361+
valid_names = {g.value for g in FeatureGroup}
362+
unknown = [name for name in self.feature_groups if name not in valid_names]
363+
if unknown:
364+
raise ValueError(
365+
f"Unknown FeatureGroup name(s): {unknown!r}. "
366+
f"Valid names: {sorted(valid_names)}."
367+
)
368+
return self
369+
326370

327371
class TrainResponse(BaseModel):
328372
"""Response body for POST /forecasting/train.
@@ -503,3 +547,27 @@ class FeatureMetadataResponse(BaseModel):
503547
"know what the numbers mean."
504548
),
505549
)
550+
# PRP-35 — purely additive V2 metadata. ``feature_frame_version`` defaults
551+
# to 1 for legacy bundles (``bundle.metadata.get("feature_frame_version", 1)``).
552+
# ``feature_groups`` / ``feature_safety_classes`` are populated for V2
553+
# bundles only and absent (None) for V1.
554+
feature_frame_version: int = Field(
555+
default=1,
556+
ge=1,
557+
le=2,
558+
description="Feature contract version recorded in the bundle metadata.",
559+
)
560+
feature_groups: dict[str, list[str]] | None = Field(
561+
default=None,
562+
description=(
563+
"V2 only: ``{group_name: [columns]}`` mapping from "
564+
"``v2_feature_groups_dict``. None for V1 bundles."
565+
),
566+
)
567+
feature_safety_classes: dict[str, str] | None = Field(
568+
default=None,
569+
description=(
570+
"V2 only: ``{column: safety.value}`` mapping from "
571+
"``v2_feature_safety_classes``. None for V1 bundles."
572+
),
573+
)

0 commit comments

Comments
 (0)