Files
foxhunt/crates/ml/tests/sp18_fold_transition_test.rs
jgrusewski 3e9cefdbd9 fix(sp18-v2): restore-best precedes shrink-and-perturb at fold transition
`train_walk_forward` called `reset_for_fold().await?` directly at the
fold boundary, which applies `shrink_and_perturb(α=0.8, σ=0.01)` to
whatever `params_flat` holds. At end-of-fold, `params_flat` carries the
LATEST-EPOCH decayed weights, NOT the best-Sharpe snapshot saved
mid-fold. Fold N+1 then started from `0.8 × decayed + noise` and
carried the within-fold edge-decay forward.

Insert `restore_best_gpu_params()` BEFORE `reset_for_fold` so S&P
operates on the best-Sharpe snapshot. Cold-start guard swallows the
"no snapshot saved" error on the first fold (or any fold without a
Sharpe improvement) — matches pre-fix behavior on cold start, no
regression.

State interaction with `reset_for_fold`: restore-best writes
`params_flat` ONLY (online weights); reset_for_fold then runs S&P on
the restored online, hard-syncs target ← restored online, and resets
Adam state on every optimizer. Non-conflicting.

`best_params_snapshot` is constructor-init `None` on FusedTrainingCtx
and is NEVER cleared at fold boundary. The trainer's `self.best_sharpe`
IS reset to NEG_INFINITY in `reset_for_fold` so the first improvement
in fold N+1 will overwrite the snapshot. Until then, the snapshot holds
whichever fold most recently saved a peak — intended training-scoped
behavior. Strict within-fold semantics flagged as a follow-up
consideration.

Behavioral test (CPU oracle) pins the math contract:
  - shrink_and_perturb(α, σ) on X = α × X + (1−α) × N(0, σ)
  - with-fix vs without-fix differ by α × (W_best − W_curr)
  - cold-start (best == current) is bit-identical between orderings

GPU-level kernel coverage stays in compile_training_kernels smoke and
the L40S 30-epoch validation gating SP18 Phase 1.

Pre-commit Invariant 7: docs/dqn-wire-up-audit.md updated with full
rationale, state-interaction analysis, lifecycle notes, and the
preserved cross-pearl invariants.

Per:
- feedback_no_partial_refactor (call-ordering + test + audit-doc atomic)
- feedback_no_legacy_aliases (reuses existing API unchanged)
- feedback_wire_everything_up (restore_best_gpu_params gains second
  cold-path consumer)
- pearl_no_host_branches_in_captured_graph (runs outside graph capture)
2026-05-09 01:36:52 +02:00

221 lines
8.8 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! SP18 v2 fold-transition restoration test (2026-05-09).
//!
//! Verifies the contract enforced by the fold-transition fix in
//! `crates/ml/src/trainers/dqn/trainer/mod.rs` — the call site that runs
//! `restore_best_gpu_params()` BEFORE `reset_for_fold()` so that
//! `shrink_and_perturb(α=0.8, σ=0.01)` operates on the best-Sharpe
//! snapshot, not on the latest-epoch decayed weights.
//!
//! The bug surfaced by the edge-decay audit on the vtj9r run was: at
//! end-of-fold, `params_flat` held HD5 decayed weights (Sharpe ~60), not
//! HD2 peak weights (Sharpe ~91). The fix restores the saved best-Sharpe
//! snapshot into `params_flat` before shrink-and-perturb, so fold N+1
//! starts from `0.8 × HD2_weights + 0.2 × noise(σ=0.01)` — preserving the
//! peak Sharpe trajectory across folds.
//!
//! ## Why this test is CPU-only
//!
//! `restore_best_gpu_params` (DtoD copy) and `shrink_and_perturb` (kernel
//! launch) are GPU operations. A faithful end-to-end test would require
//! constructing a `GpuDqnTrainer` (heavy: param-buf alloc, cubin loads,
//! CUDA stream, full device init) and exercising the live GPU path —
//! deferred to L40S smoke.
//!
//! Instead, this test pins the MATH CONTRACT that the fix enforces:
//!
//! 1. `shrink_and_perturb(α, σ)` applied to a buffer X writes
//! `α × X + (1α) × N(0, σ)` to that buffer.
//! 2. The fix-required ordering is: restore-best (X ← best),
//! then shrink_and_perturb (X ← α × best + ...).
//! 3. The bugged ordering would have been: shrink_and_perturb on the
//! raw end-of-fold X (X ← α × decayed + ...), giving a result that
//! diverges from the best-Sharpe snapshot trajectory.
//!
//! The test simulates both orderings on a synthetic 1024-element buffer
//! and confirms that with-fix ≠ without-fix when best ≠ current. This
//! catches a regression where someone reorders the calls or removes the
//! restore-best step entirely.
//!
//! Real GPU coverage of the kernel-level math is provided by the existing
//! `compile_training_kernels` smoke + the L40S 30-epoch validation that
//! gates the SP18 Phase 1 dispatch.
use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
/// CPU-side oracle for `shrink_and_perturb(α, σ)` applied to one buffer X.
///
/// Mirrors the kernel's `params_buf[i] = α × params_buf[i] + (1α) × N(0, σ)`
/// formula. The kernel uses on-device noise via the (adam_step × Knuth)
/// seed; here we use `StdRng::seed_from_u64` to make the test deterministic
/// — the noise distribution properties (mean 0, std σ) are what matter.
///
/// Returns `α × X + (1α) × noise`.
fn shrink_and_perturb_cpu(
x: &[f32],
alpha: f32,
sigma: f32,
seed: u64,
) -> Vec<f32> {
let mut rng = StdRng::seed_from_u64(seed);
x.iter()
.map(|&v| {
// Box-Muller for f32 N(0, σ).
let u1: f32 = rng.gen_range(1e-7..1.0);
let u2: f32 = rng.gen_range(0.0..1.0);
let r = (-2.0_f32 * u1.ln()).sqrt();
let theta = 2.0_f32 * std::f32::consts::PI * u2;
let n = sigma * r * theta.cos();
alpha * v + (1.0 - alpha) * n
})
.collect()
}
/// L2 distance between two equal-length f32 buffers.
fn l2_dist(a: &[f32], b: &[f32]) -> f32 {
assert_eq!(a.len(), b.len());
let sumsq: f32 = a.iter().zip(b.iter()).map(|(&x, &y)| (x - y).powi(2)).sum();
sumsq.sqrt()
}
/// Test: with the fix in place, fold-transition operates on the best
/// snapshot. The pre-fix ordering would have operated on the current
/// (decayed) weights instead.
///
/// Synthetic setup matches the vtj9r audit narrative:
/// - W_best = a smooth weight pattern at peak Sharpe (HD2)
/// - W_curr = W_best + drift_amplitude × random_pattern (HD5 decayed)
///
/// Same RNG seed for both orderings (so the noise term is identical and the
/// only difference is which weights act as the multiplicand). The L2
/// distance between with-fix and without-fix output equals
/// `α × ||W_best W_curr||₂` per the formula:
///
/// with_fix = 0.8 × W_best + 0.2 × N
/// without_fix = 0.8 × W_curr + 0.2 × N
/// diff = 0.8 × (W_best W_curr)
///
/// We pick a ||W_best W_curr|| target and verify the diff matches within
/// f32 tolerance.
#[test]
fn shrink_and_perturb_after_restore_uses_best_not_current() {
const N: usize = 1024;
const ALPHA: f32 = 0.8;
const SIGMA: f32 = 0.01;
const SEED: u64 = 42;
// W_best: deterministic pattern.
let w_best: Vec<f32> = (0..N).map(|i| (i as f32 * 0.001).sin()).collect();
// W_curr: W_best + meaningful drift (simulating HD2 → HD5 edge-decay).
// Drift amplitude 0.05 × W_best gives ||drift||₂ ≈ 0.05 × ||W_best||₂.
let drift_amp = 0.05_f32;
let w_curr: Vec<f32> = w_best
.iter()
.enumerate()
.map(|(i, &w)| w + drift_amp * (i as f32 * 0.013).cos())
.collect();
// Sanity: the two are different.
let dist_best_curr = l2_dist(&w_best, &w_curr);
assert!(
dist_best_curr > 1e-3,
"test setup: W_best and W_curr should differ (got dist {})",
dist_best_curr,
);
// With-fix path: restore-best then S&P. params_flat ← W_best, then
// shrink_and_perturb is called on params_flat.
let with_fix = shrink_and_perturb_cpu(&w_best, ALPHA, SIGMA, SEED);
// Without-fix path: S&P called directly on params_flat which still
// holds W_curr (the bug we're fixing).
let without_fix = shrink_and_perturb_cpu(&w_curr, ALPHA, SIGMA, SEED);
// The two outputs MUST differ.
let dist_outputs = l2_dist(&with_fix, &without_fix);
assert!(
dist_outputs > 1e-3,
"with-fix and without-fix S&P outputs should differ (got dist {})",
dist_outputs,
);
// Per the formula, the diff is exactly `α × (W_best W_curr)`.
let expected_diff_norm = ALPHA * dist_best_curr;
let actual_diff_norm = dist_outputs;
assert!(
(actual_diff_norm - expected_diff_norm).abs() < 1e-4,
"L2(with_fix, without_fix) should equal α × L2(W_best, W_curr): \
expected {}, got {} (diff {:.3e})",
expected_diff_norm, actual_diff_norm, (actual_diff_norm - expected_diff_norm).abs(),
);
}
/// Test: with-fix output respects the `0.8 × W_best + small_noise` bound.
/// Specifically, `||with_fix 0.8 × W_best||₂ ≈ 0.2 × σ × √N` (one std
/// of summed Gaussian noise). The Wilson 95% CI for that scale is
/// [0.5×, 2.0×] — generous because the test only checks order-of-magnitude.
#[test]
fn fold_transition_output_is_dominated_by_best_snapshot() {
const N: usize = 1024;
const ALPHA: f32 = 0.8;
const SIGMA: f32 = 0.01;
const SEED: u64 = 7;
// W_best: zero pattern simplifies the noise-extraction.
let w_best: Vec<f32> = vec![0.5; N];
// S&P on best.
let out = shrink_and_perturb_cpu(&w_best, ALPHA, SIGMA, SEED);
// Subtract α × W_best — what's left is (1α) × noise.
let residual: Vec<f32> = out
.iter()
.zip(w_best.iter())
.map(|(&o, &w)| o - ALPHA * w)
.collect();
let residual_norm: f32 = residual.iter().map(|x| x * x).sum::<f32>().sqrt();
// Expected scale: (1α) × σ × √N for sum of N i.i.d. Gaussians.
let expected = (1.0 - ALPHA) * SIGMA * (N as f32).sqrt();
// 0.5× to 2.0× window per the docstring rationale.
assert!(
residual_norm > 0.5 * expected && residual_norm < 2.0 * expected,
"residual norm {} should fall within [{:.3e}, {:.3e}] (expected ≈ {:.3e})",
residual_norm, 0.5 * expected, 2.0 * expected, expected,
);
}
/// Test: when W_best and W_curr coincide (no edge-decay yet), with-fix and
/// without-fix produce IDENTICAL outputs. This pins the cold-start path:
/// on the first fold (or any fold where no best-Sharpe improvement has
/// been observed yet), the fix is a no-op — `params_flat` already holds
/// the freshly-trained weights, and there's nothing to restore.
///
/// This also validates the cold-start error-swallow at the call site: if
/// `restore_best_gpu_params` returns Err (no snapshot), the subsequent
/// `reset_for_fold` runs S&P on the still-current weights — same outcome
/// as if the fix weren't in place. No regression.
#[test]
fn fold_transition_is_noop_when_best_equals_current() {
const N: usize = 256;
const ALPHA: f32 = 0.8;
const SIGMA: f32 = 0.01;
const SEED: u64 = 99;
let w: Vec<f32> = (0..N).map(|i| (i as f32 * 0.01).sin()).collect();
let with_fix = shrink_and_perturb_cpu(&w, ALPHA, SIGMA, SEED);
let without_fix = shrink_and_perturb_cpu(&w, ALPHA, SIGMA, SEED);
// Bit-identical (same RNG seed, same input).
for (i, (a, b)) in with_fix.iter().zip(without_fix.iter()).enumerate() {
assert_eq!(
a.to_bits(), b.to_bits(),
"outputs should be bit-identical when best == current (idx {}: {} vs {})",
i, a, b,
);
}
}