Files
foxhunt/crates/ml/tests/multi_horizon_reward_blend_test.rs
jgrusewski d39005c6f4 feat(sp19 commit b): producer-side multi-horizon reward blend at fxcache write time
Path (B) Commit B — load-bearing semantic change for the SP19 multi-
horizon reward augmentation. At fxcache-write time, blend 1-bar / 5-bar
/ 30-bar log-returns into `tgt[1]` (`preproc_next`) using equal-thirds
weights with `1/sqrt(N)` vol-scale correction:

    blended = (1/3) * r_1
            + (1/3) * r_5  / sqrt(5)
            + (1/3) * r_30 / sqrt(30)

The vol-scale correction applies INSIDE the blend (before weighting) so
each horizon's log-return is at unit-volatility-equivalent under
random-walk assumption — drops naturally into the existing `tgt[1]`
preprocessing pipeline (consumed in `experience_kernels.cu:1556` and
`cuda_pipeline/mod.rs:508`) without double-scaling.

Producer trim: valid range shrinks by `LOOKAHEAD_HORIZON_MAX = 30`
bars since `r_30` needs `close[i + WARMUP + 30]`. Walk-forward fold
construction is dataset-relative, so trimming at producer time shifts
every fold's tail by 30 bars — one-time cost per fxcache regen.

Both producer call sites (`precompute_features.rs` for the
feature-precompute binary, `data_loading.rs` for the DBN-fallback
in-process path) change atomically per `feedback_no_partial_refactor`.
Kernel consumers are unchanged — only `tgt[1]`'s value composition
differs from the consumer's perspective.

ISV slots [507..510) (allocated in Commit A) are NOT consumed at
producer time — `precompute_features` runs BEFORE training so the
ISV bus isn't initialised when the blend happens. Producer hardcodes
1/3 each via `SP19_HORIZON_BLEND_WEIGHTS`. Future Path (A) refactor
would bump TARGET_DIM to carry per-horizon log-returns and read
ISV at training-step time; for the empirical hypothesis test
("does multi-horizon blend lift WR?") equal-thirds is sufficient.

fxcache schema invalidation: `FXCACHE_VERSION` 8 → 9. Existing
`.fxcache` files (1-bar-only `preproc_next`) fail validation at load
and trigger Argo's ensure-fxcache regen step. First L40S run after
this commit takes ~10-15 min longer for the regen — one-time cost,
expected.

Touches:
- crates/ml/examples/precompute_features.rs: SP19_HORIZON_BLEND_WEIGHTS
  constant + LOOKAHEAD_HORIZON_MAX trim + blend in tgt[] writer +
  early-bail-out check on minimum dataset size.
- crates/ml/src/trainers/dqn/data_loading.rs: same blend +
  trim in DBN-fallback path; `last sample targets itself` block
  removed (the trimmed range guarantees feature-vector and target
  lengths match without a sentinel last row).
- crates/ml/src/fxcache.rs: FXCACHE_VERSION 8 → 9 + v9 docstring entry.
- crates/ml/tests/multi_horizon_reward_blend_test.rs (NEW): CPU-only
  oracle behavioural test — 4 cases including known returns, trim
  contract, zero-close short-circuit, constant-price blend.
- docs/dqn-wire-up-audit.md: Concerns subsection appended to the
  SP19 Commit B entry already drafted in Commit A (pre-existing
  test_fxcache_empty + test_dqn_checkpoint_round_trip flakiness
  documented for Invariant 7).

Verification:
SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 cargo check --workspace        clean
cargo test -p ml --test multi_horizon_reward_blend_test              4/4 pass
cargo test -p ml --test fxcache_roundtrip_test test_fxcache_f32_roundtrip  passes (TARGET_DIM unchanged)
cargo test -p ml --lib ...                                           13 baseline failures (pre-existing); zero new regressions
bash scripts/audit_sp18_consumers.sh --check                         exit 0

Pre-existing test_fxcache_empty failure: hand-crafts a 64-byte header
but the actual header is 72 bytes; reader bails early on
"failed to fill whole buffer" instead of "bar_count=0". Test setup
bug, NOT a regression. Pre-Commit B failure count: 13. Post-Commit B
failure count: 13 (the test_dqn_checkpoint_round_trip test is flaky
and toggles independently of this change — verified by stashing and
re-running 3× pre-Commit B).

Atomic-refactor invariant satisfied: Commit A (slot reservations) +
Commit B (producer-side blend) land on the same branch with no L40S
dispatch between. Per task instruction the branch stays unpushed
pending user review.

DBN-fallback path: applied identically in `data_loading.rs:497-528`.
Both producers use the same `SP19_HORIZON_BLEND_WEIGHTS` constant and
the same `LOOKAHEAD_HORIZON_MAX = 30` trim.

Vol-scale correction site: applied inside the blend (before weighting)
in BOTH producers. The existing `tgt[1]` preprocessing pipeline does
NOT double-scale — `experience_kernels.cu:1556` and
`cuda_pipeline/mod.rs:508` consume `tgt[1]` as a unit-scale log-return
exactly as before the blend was added; the blended value drops in
without further scaling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:45:20 +02:00

186 lines
7.9 KiB
Rust

//! SP19 Path (B) — producer-side multi-horizon reward blend behavioural test.
//!
//! Verifies the blend math at fxcache-write time with a CPU-only oracle:
//!
//! blended = (1/3) * r_1 + (1/3) * r_5 / sqrt(5) + (1/3) * r_30 / sqrt(30)
//!
//! where `r_N = ln(close[i + N] / close[i])`. The producer code lives in
//! `crates/ml/examples/precompute_features.rs` and
//! `crates/ml/src/trainers/dqn/data_loading.rs`; this test re-implements the
//! blend with the SAME `SP19_HORIZON_BLEND_WEIGHTS` constant and asserts
//! match to ε=1e-9 across a synthetic 100-bar trajectory.
//!
//! Edge case: bars within `LOOKAHEAD_HORIZON_MAX = 30` of the dataset end
//! are EXCLUDED — the trim is `n = len - LOOKAHEAD_HORIZON_MAX`. We assert
//! the trim is exact by checking output length and bar-index range.
//!
//! Per `feedback_no_partial_refactor`, the test asserts the producer math
//! AND the trim contract simultaneously, so a regression in either dimension
//! fires the same gate.
#![allow(clippy::float_cmp)]
const WARMUP: usize = 50;
const LOOKAHEAD_HORIZON_MAX: usize = 30;
const SP19_HORIZON_BLEND_WEIGHTS: [f64; 3] = [
1.0_f64 / 3.0_f64,
1.0_f64 / 3.0_f64,
1.0_f64 / 3.0_f64,
];
/// Mirror of the producer-side blend in `precompute_features.rs` /
/// `data_loading.rs`. Returns the blended log-return at bar `i`.
///
/// Pre-condition: `i + WARMUP + LOOKAHEAD_HORIZON_MAX < closes.len()`.
fn blended_log_return(closes: &[f64], i: usize) -> f64 {
let raw_curr = closes[i + WARMUP];
let raw_next = closes[i + WARMUP + 1];
let raw_5bar = closes[i + WARMUP + 5];
let raw_30bar = closes[i + WARMUP + 30];
let log_return_1bar = if raw_curr > 0.0 { (raw_next / raw_curr).ln() } else { 0.0 };
let log_return_5bar = if raw_curr > 0.0 { (raw_5bar / raw_curr).ln() / (5.0_f64).sqrt() } else { 0.0 };
let log_return_30bar = if raw_curr > 0.0 { (raw_30bar / raw_curr).ln() / (30.0_f64).sqrt() } else { 0.0 };
SP19_HORIZON_BLEND_WEIGHTS[0] * log_return_1bar
+ SP19_HORIZON_BLEND_WEIGHTS[1] * log_return_5bar
+ SP19_HORIZON_BLEND_WEIGHTS[2] * log_return_30bar
}
/// Build a deterministic synthetic close-price trajectory. Geometric
/// drift `μ_per_bar = 0.001` plus 5-bar / 30-bar cyclic perturbation
/// keeps the 1-bar / 5-bar / 30-bar log-returns measurably distinct so
/// the blend's contribution is non-degenerate.
fn synthetic_closes(n: usize) -> Vec<f64> {
let mu = 0.001_f64;
(0..n)
.map(|i| {
let drift = (i as f64) * mu;
let cyc5 = ((i as f64) * std::f64::consts::PI / 5.0).sin() * 0.01;
let cyc30 = ((i as f64) * std::f64::consts::PI / 30.0).cos() * 0.005;
5000.0_f64 * (drift + cyc5 + cyc30).exp()
})
.collect()
}
#[test]
fn sp19_blend_matches_oracle_for_known_returns() {
// Engineered close prices producing exact integer-ratio log-returns
// at bar 0 (after WARMUP). With WARMUP = 50 and a 100-bar trajectory
// the valid output range is `[0, 100 - WARMUP - 30) = [0, 20)`.
let n_bars = WARMUP + 50; // 100 total bars
let closes = synthetic_closes(n_bars);
let n_outputs = closes.len() - WARMUP - LOOKAHEAD_HORIZON_MAX;
assert!(n_outputs > 0, "synthetic dataset too small to produce any blended output");
// Hand-checked oracle for bar i = 0
let p_curr = closes[WARMUP];
let p_1 = closes[WARMUP + 1];
let p_5 = closes[WARMUP + 5];
let p_30 = closes[WARMUP + 30];
let oracle_1 = (p_1 / p_curr).ln();
let oracle_5 = (p_5 / p_curr).ln() / (5.0_f64).sqrt();
let oracle_30 = (p_30 / p_curr).ln() / (30.0_f64).sqrt();
let oracle_blend =
SP19_HORIZON_BLEND_WEIGHTS[0] * oracle_1
+ SP19_HORIZON_BLEND_WEIGHTS[1] * oracle_5
+ SP19_HORIZON_BLEND_WEIGHTS[2] * oracle_30;
let producer = blended_log_return(&closes, 0);
let diff = (producer - oracle_blend).abs();
assert!(
diff < 1.0e-9,
"SP19 blend mismatch at bar 0: producer={} oracle={} diff={}",
producer, oracle_blend, diff
);
// Sweep all valid output bars to catch indexing drift.
for i in 0..n_outputs {
let producer = blended_log_return(&closes, i);
// Rebuild the oracle at this bar inline (no helper to avoid
// tautological self-reference — recompute from raw closes).
let p_curr = closes[i + WARMUP];
let p_1 = closes[i + WARMUP + 1];
let p_5 = closes[i + WARMUP + 5];
let p_30 = closes[i + WARMUP + 30];
let r1 = (p_1 / p_curr).ln();
let r5 = (p_5 / p_curr).ln() / (5.0_f64).sqrt();
let r30 = (p_30 / p_curr).ln() / (30.0_f64).sqrt();
let oracle = (r1 + r5 + r30) / 3.0;
let diff = (producer - oracle).abs();
assert!(
diff < 1.0e-12,
"SP19 blend mismatch at bar {}: producer={} oracle={} diff={}",
i, producer, oracle, diff
);
}
}
#[test]
fn sp19_trim_excludes_last_lookahead_horizon_max_bars() {
// n_outputs = feature_vectors.len() - LOOKAHEAD_HORIZON_MAX
// = (closes.len() - WARMUP) - LOOKAHEAD_HORIZON_MAX
let closes_lens = [
WARMUP + LOOKAHEAD_HORIZON_MAX + 1, // boundary: exactly 1 output
WARMUP + LOOKAHEAD_HORIZON_MAX + 10, // 10 outputs
WARMUP + LOOKAHEAD_HORIZON_MAX + 100, // 100 outputs
];
for &n_bars in &closes_lens {
let feature_vectors_len = n_bars - WARMUP;
let expected_outputs = feature_vectors_len.saturating_sub(LOOKAHEAD_HORIZON_MAX);
// Producer trim formula: `n = feature_vectors.len() - LOOKAHEAD_HORIZON_MAX`.
let producer_trim = feature_vectors_len.saturating_sub(LOOKAHEAD_HORIZON_MAX);
assert_eq!(
producer_trim, expected_outputs,
"producer trim mismatch at n_bars={}: producer={} expected={}",
n_bars, producer_trim, expected_outputs
);
// Last valid bar index `i = expected_outputs - 1` accesses
// `all_bars[i + WARMUP + 30] = all_bars[n_bars - 1]` — the last
// bar in the dataset, in bounds by construction.
if expected_outputs > 0 {
let last_i = expected_outputs - 1;
let last_access_idx = last_i + WARMUP + LOOKAHEAD_HORIZON_MAX;
assert!(
last_access_idx < n_bars,
"trim contract violated at n_bars={}: last_access_idx={} >= n_bars",
n_bars, last_access_idx
);
// The next-bar-after-last-output access would go out of bounds —
// confirms the trim is tight (no off-by-one slack).
let next_i = expected_outputs;
let next_access_idx = next_i + WARMUP + LOOKAHEAD_HORIZON_MAX;
assert!(
next_access_idx >= n_bars,
"trim contract too loose at n_bars={}: next_access_idx={} should be >= n_bars",
n_bars, next_access_idx
);
}
}
}
#[test]
fn sp19_blend_zero_when_close_is_zero() {
// Degenerate close = 0.0 at bar `i + WARMUP` short-circuits the blend
// to 0.0 (the producer guards `if raw_curr > 0.0` per
// `precompute_features.rs:374` analogue). This is the same guard the
// 1-bar-only `preproc_next` had pre-SP19.
let mut closes = vec![5000.0_f64; WARMUP + LOOKAHEAD_HORIZON_MAX + 5];
closes[WARMUP] = 0.0;
let blended = blended_log_return(&closes, 0);
assert_eq!(blended, 0.0, "zero close must short-circuit blend to 0.0");
}
#[test]
fn sp19_blend_constant_price_yields_zero_blend() {
// Constant price ⇒ all log-returns are zero ⇒ blend is zero.
let closes = vec![5000.0_f64; WARMUP + LOOKAHEAD_HORIZON_MAX + 5];
for i in 0..(closes.len() - WARMUP - LOOKAHEAD_HORIZON_MAX) {
let blended = blended_log_return(&closes, i);
assert!(
blended.abs() < 1.0e-15,
"constant price must blend to ≈0 at bar {}: got {}", i, blended
);
}
}