fix(audit): lookahead housekeeping — purge gap + per-fold NormStats

Closes recs 2 + 3 in lookahead-bias-audit-2026-04-28.md.

Fix 2A (rec 2): add walk_forward::PURGE_BARS = 5 constant and insert
val_start = train_end + PURGE_BARS in all 4 fold-construction sites:
generate_walk_forward_indices, _from_timestamps, _windows (date-based,
drops first PURGE_BARS bars of val), and gpu_walk_forward::generate_folds
(both stratified variants preserve the gap on boundary shift). 5-bar
width matches max per-bar feature lookback (autocorr lags 1/5/10);
compile-time per feedback_isv_for_adaptive_bounds since the
feature-pipeline lookback is itself compile-time.

Fix 2B (rec 3): per-fold NormStats fit from train-only data.
- walk_forward.rs: add from_features_slice + denormalize/denormalize_batch
  helpers (inverse of normalize_batch for clamp-bounded round-trip).
- train_baseline_rl.rs fold loop: load fxcache sidecar norm_stats.json,
  denormalise back to RAW, refit per-fold via from_features_slice,
  renormalise full dataset, re-upload via init_from_fxcache, save
  per-fold norm_stats_fold{N}.json. DBN-fallback path keeps legacy
  behaviour (no sidecar to denormalise from) with a warn! log.
  Ensemble trainer block is documented follow-up (separate code path).

Behavioral tests:
- test_norm_stats_per_fold_fit_train_only: synthetic train-mean=1.0 /
  val-mean=9.0 dataset; from_features_slice recovers train-only mean to
  ε=1e-5 while from_features returns global 5.0
- test_norm_stats_denormalize_roundtrip: non-clamped features round-trip
  bit-identically
- test_walk_forward_purge_gap_indices_from_timestamps: every fold has
  val_start - train_end == PURGE_BARS
- test_fold_generation_basic (gpu_walk_forward): updated to expect the
  purge gap

Validation: cargo check --workspace clean (12.30s); 20/20 walk_forward
+ gpu_walk_forward tests pass; audit_sp18_consumers.sh --check exit 0.
Pre-existing test failures on local RTX 3050 Ti are unrelated SIGSEGVs
(VRAM exhaustion in chunked Thompson tests, pre-existing on baseline).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-05-09 13:52:00 +02:00
parent e140392f86
commit ce841eb56e
5 changed files with 535 additions and 38 deletions

View File

@@ -2,6 +2,105 @@
**Status:** Populated during Plan 1 Task 6 (A.5 orphan audit). Updated on every commit per Invariant 7.
## 2026-05-09 — Lookahead audit housekeeping: purge gap + per-fold NormStats
Closes recs 2 + 3 in `docs/lookahead-bias-audit-2026-04-28.md`. Two
correctness fixes, one atomic commit per `feedback_no_partial_refactor`:
**Fix 2A — train→val purge gap (rec 2)**
Adds `walk_forward::PURGE_BARS = 5` constant and inserts it as
`val_start = train_end + PURGE_BARS` in all 4 fold-construction
sites:
- `walk_forward::generate_walk_forward_indices` (bars-based)
- `walk_forward::generate_walk_forward_indices_from_timestamps`
(fxcache-timestamps-based, the production training path)
- `walk_forward::generate_walk_forward_windows` (date-based, drops
first PURGE_BARS bars of val window since the partition is by
calendar boundary not bar index)
- `gpu_walk_forward::GpuWalkForwardConfig::generate_folds` and both
stratified variants (CPU `generate_folds_stratified` + GPU
`generate_folds_stratified_gpu`) — boundary-shift logic preserves
the gap when shifting (`new_train_end = new_val_start.saturating_sub(PURGE_BARS)`)
5-bar width chosen as the maximum per-bar feature lookback (autocorr
lags 1/5/10 at `extraction.rs:484-491`, price-acceleration window 3
at `extraction.rs:797-810`). Compile-time per
`feedback_isv_for_adaptive_bounds` because the feature-pipeline
lookback is itself compile-time — this is structural correctness,
not training-dynamics adaptive.
**Fix 2B — per-fold NormStats fit (rec 3)**
Adds `NormStats::from_features_slice(features, train_start, train_end)`
helper + `denormalize` / `denormalize_batch` inverses to
`walk_forward.rs`. Modifies `train_baseline_rl.rs` fold loop to:
1. Load fxcache sidecar `norm_stats.json` (precompute-time global
stats), denormalise `fxcache.features` back to RAW via
`denormalize_batch`. Caveat: `|z|=20` clamp boundary is lossy
on round-trip; non-clamped bars round-trip exact.
2. Per fold: fit `NormStats::from_features_slice(raw, train_start,
train_end)` — train-only data, no val leakage.
3. Renormalise full dataset with fold-specific stats, re-upload to
GPU via `init_from_fxcache` (replaces `gpu_data` field per
`dqn::trainer::mod.rs::init_from_fxcache:2064`; replay buffer
reset by `reset_for_fold` so prior-fold features can't carry
over).
4. Save per-fold `norm_stats_fold{N}.json` reflecting the fit
(replaces the legacy "copy the shared file" approach).
**Known follow-ups** (not in this commit's scope):
- DBN-fallback path: when no sidecar JSON exists, `raw_features` is
`None` and the legacy global-fit path is preserved with a `warn!`
log line. Workflows that hit DBN-fallback (148 GB MBP-10 parsing
cold start) should regenerate the fxcache to gain rec 3.
- Ensemble trainer constructor block (`train_baseline_rl.rs:843-857`)
uploads global-fit features once per ensemble member before the
fold loop; the rec-3 fix re-uploads only the primary trainer per
fold. Hyperopt-ensembling is a separate code path from the
baseline val-window investigation that motivated this audit, so
the ensemble path keeps legacy behaviour.
**Behavioral tests**:
- `test_norm_stats_per_fold_fit_train_only` — synthetic dataset
with train mean=1.0 / val mean=9.0; `from_features_slice` returns
train-only mean to ε=1e-5, `from_features` returns global mean
5.0; explicit assertion `mean_diff > 1.0` proves the two paths
diverge as expected.
- `test_norm_stats_denormalize_roundtrip` — non-clamped features
round-trip bit-identically through denormalize→normalize.
- `test_walk_forward_purge_gap_indices_from_timestamps` — every
fold satisfies `val_start - train_end == PURGE_BARS`.
- `test_fold_generation_basic` (gpu_walk_forward) — updated to
expect the purge gap in fold-0 / fold-1 boundary indices.
Atomic commit per `feedback_no_partial_refactor`:
- `crates/ml/src/walk_forward.rs``PURGE_BARS` constant +
`from_features_slice` + `denormalize`/`denormalize_batch` helpers
+ 3 fold-construction sites updated + 3 behavioral tests
- `crates/ml/src/cuda_pipeline/gpu_walk_forward.rs` — purge gap
in `generate_folds` + boundary-shift logic in both stratified
variants + `test_fold_generation_basic` updated
- `crates/ml/examples/train_baseline_rl.rs` — fold loop
per-fold renormalisation + re-upload + per-fold JSON save;
fallback path retained with warn log when sidecar JSON
unavailable (DBN-fallback or zero cache_key)
- `docs/lookahead-bias-audit-2026-04-28.md` — recs 2/3 marked
RESOLVED with implementation + caveats
Pearls applied: `feedback_no_partial_refactor` (4 fold-construction
sites updated atomically), `feedback_isv_for_adaptive_bounds`
(PURGE_BARS documented as structural-not-tuned), `feedback_wire_everything_up`
(helpers + production wiring + tests in same commit),
`feedback_no_legacy_aliases` (the rec-3 wiring REPLACES the legacy
`shared_norm_stats_src` copy — no half-migrated state).
Validation: `cargo check --workspace` clean (12.30s); 20/20
walk_forward + gpu_walk_forward tests pass; `bash
scripts/audit_sp18_consumers.sh --check` exit 0; pre-existing
test failures unrelated to this commit (all SIGSEGV in tests
that exhaust local RTX 3050 Ti VRAM, pre-existing on baseline).
## 2026-05-09 — Per-regime val WR instrumentation (audit follow-up)
Adds 6 output slots [13..19) to `compute_backtest_metrics_kernel`

View File

@@ -72,12 +72,37 @@ The validation backtest and the training experience-replay BOTH gather features
## 4. Walk-forward fold split (crates/ml/src/walk_forward.rs)
- **CONFIDENCE**: suspicious
- **CONFIDENCE**: suspicious**RESOLVED 2026-05-09**
- `generate_walk_forward_indices` at `walk_forward.rs:454`: train `[0..train_end_idx)`, val `[val_start..val_end_bound)` with `val_start = train_end_idx` (`walk_forward.rs:495`). Same in `generate_walk_forward_indices_from_timestamps` (`walk_forward.rs:584`).
- **No purge/embargo gap** between train and val. For per-bar labels with `target = next_close` this leaks at most one bar (the last training bar's `next_close` target = first val bar's `close`). On 4M-bar datasets that single-bar overlap is negligible for Sharpe inflation, but worth flagging.
- **RESOLVED 2026-05-09**: `walk_forward.rs::PURGE_BARS = 5` constant inserted between `train_end` and `val_start` in all four fold-construction sites:
`generate_walk_forward_indices`, `generate_walk_forward_indices_from_timestamps`,
`generate_walk_forward_windows` (date-based, drops first 5 bars of val window),
and `gpu_walk_forward.rs::GpuWalkForwardConfig::generate_folds` (+ both stratified variants).
5 bars chosen as the maximum lookback of any per-bar feature (autocorr lags 1/5/10 at
`extraction.rs:484-491`, price-acceleration window 3 at `extraction.rs:797-810`).
Behavioral test: `test_walk_forward_purge_gap_indices_from_timestamps` confirms
`val_start - train_end == PURGE_BARS` for all generated folds.
- More serious: **`NormStats::from_features` is computed on the FULL dataset (train + val combined) at `precompute_features.rs:647`** before the walk-forward split happens. The fold loop then reads pre-normalised features from the fxcache. The mean/std used to z-score training data already incorporate validation-period statistics. Defence-in-depth normalization in the DBN fallback (`train_baseline_rl.rs:631`) repeats the same pattern — `from_features(&all_features_raw)` over the entire span.
- This is a textbook normalisation leak. The contract documented at `walk_forward.rs:25` ("Compute stats from training data only") is violated by the actual call site. `train_baseline_supervised.rs:466-468` does it correctly (per-fold `NormStats::from_features(&train_features)`); RL training does not.
- Magnitude of impact is bounded (mean/std drift across a 4M-bar futures series is small, sub-percent for stationary features), but for non-stationary slots like price-derived ratios it can shift the input distribution by ~0.1σ. Not the primary explanation for Sharpe=196 by itself, but compounds with §3.
- **RESOLVED 2026-05-09**: `train_baseline_rl.rs` fold loop now refits `NormStats`
from train-only RAW features per fold via `NormStats::from_features_slice`,
renormalises the entire dataset with the fold-specific stats, and re-uploads
to GPU via `init_from_fxcache` at fold boundary. RAW features are recovered
by reading the fxcache sidecar `norm_stats.json` (saved at precompute time)
and applying `denormalize_batch`. Per-fold `norm_stats_fold{N}.json` is
saved to the output directory (replacing the legacy single-stats copy)
so `evaluate_baseline` finds train-only-fit stats per fold.
Behavioral test: `test_norm_stats_per_fold_fit_train_only` constructs a
synthetic dataset where the train slice has mean 1.0 and val slice has
mean 9.0; `from_features_slice` returns the train-only mean to ε=1e-5
while `from_features` returns the global mean of 5.0. **Caveats**: (1)
fxcache features at the clamp boundary `|z|=20` (rare, corruption only)
round-trip lossy through the `denormalize` path; (2) the DBN-fallback
path and ensemble-trainer constructor block still use global-fit features —
flagged as a known follow-up. The canonical primary training path is
fixed.
## 5. Validation backtest (gpu_backtest_evaluator + backtest_env_kernel)