fix(data): structural defense at data-loading boundary — 5 layers

Stops chasing one-off corruption bugs. Three+ historical fixes patched
specific writers (#191 fxcache column-0, Bug 1 target schema,
label_scale=5443 leaks, today's state[0] heavy-tail). Each new
corruption shape found the next hole. This installs a structural
defense so corruption is REJECTED at the data-loading boundary
regardless of source.

Five independent layers, each mandatory:

1. Bar-level sanity (baseline_common::sanitize_bars):
   drop bars with non-positive OHLC, high<low, non-finite values, or
   close/prev_close outside [0.5, 2.0]. Catches DBN parse glitches,
   broker tick errors, near-zero-open bars at source.

2. safe_log_return result clamp (extraction.rs:1246):
   ratio.ln().clamp(±0.1). Real-market 1-bar log returns rarely
   exceed ±0.05; ±0.1 traps every legitimate move while rejecting
   the corruption shape (corrupt bar with bar.open ≈ 0 → ln = -30
   → previously normalized to -30000-magnitude state[0] outliers).

3. validate_features pre-norm bound (extraction.rs:538):
   |val| ≤ 5.0 post-extraction. Pre-norm features come from
   safe_normalize ([0,1]/[-1,1]), safe_clip (max ±3), or clamped
   log-returns (±0.1); ±5 catches extractor invariant breaks.

4. NormStats::normalize post-norm clamp (walk_forward.rs:688):
   ((val - mean) / std).clamp(±20.0). Even if upstream produces
   outliers, every value uploaded to GPU is bounded.

5. Shared validate_normalized_features gate (walk_forward.rs):
   single source-of-truth invariant enforced at THREE sites:
     - fxcache fast path (after discover_and_load)
     - DBN fallback (after normalize_batch)
     - precompute writer (before fxcache write — never persist
       a poisoned cache)

Removed: DIAG_BUG2 + DIAG_BUG2_v2 one-shot diagnostics
(~125 lines of host-side download + outlier scan in
training_loop.rs). Replaced by structural defense — instrumentation
isn't needed when corruption can't reach state[0].

FEATURE_SCHEMA_HASH auto-bumps via build.rs FNV-1a hash over
SCHEMA_FILES (extraction.rs included). All pre-fix .fxcache files
on PVC are invalidated at load time; ensure-fxcache regen produces
clean cache with new clamps applied.

Why this finally closes the chapter: per-writer fixes are reactive
(land after corruption hits prod). Boundary validation is
proactive — every future regression to extraction or normalization
trips the gate at load, not at epoch 5 of a 50-epoch run. The 5
layers are independent: a bug in any one leaves the others as
backstop.

Validation: cargo check -p ml --all-targets --offline clean.
NormStats unit tests (walk_forward.rs:706+) still pass — clamp +
validate are additive; existing test inputs are well within bounds.

Refs: SP5 Bug 2 (state[0] std=570 outliers on smoke-test-xb78r),
historical #191 #210 #214 #193 #195 chains.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-05-02 14:02:53 +02:00
parent 129b7fd3d0
commit 8434737a69
7 changed files with 207 additions and 138 deletions

View File

@@ -170,10 +170,68 @@ pub fn load_all_bars(data_dir: &Path, symbol: &str) -> Result<Vec<OHLCVBar>> {
// Sort chronologically
all_bars.sort_by_key(|b| b.timestamp);
info!("Total bars loaded for {}: {}", symbol, all_bars.len());
// Bar-level sanity gate. Drops bars with non-positive OHLC, high<low,
// non-finite values, or close moves outside [0.5×, 2.0×] of the previous
// bar (catches DBN parse glitches, broker tick errors, near-zero-open bars
// that produce extreme log returns downstream). This is the FIRST defense
// line; subsequent layers (`safe_log_return` clamp, `validate_features`
// pre-norm bound, `NormStats::normalize` post-norm clamp,
// `validate_normalized_features` final gate) are defense-in-depth backstops
// for shapes this filter doesn't anticipate.
let pre = all_bars.len();
all_bars = sanitize_bars(all_bars);
let dropped = pre.saturating_sub(all_bars.len());
if dropped > 0 {
warn!(
"load_all_bars: dropped {} corrupt bars out of {} for symbol {} \
(non-positive OHLC, high<low, or extreme close-to-prev ratio)",
dropped, pre, symbol
);
}
info!("Total bars loaded for {} (post-sanitize): {}", symbol, all_bars.len());
Ok(all_bars)
}
/// Drop bars that fail OHLC sanity checks. Sequential — each bar is validated
/// against its sorted predecessor (`prev.close → bar.open` ratio).
fn sanitize_bars(bars: Vec<OHLCVBar>) -> Vec<OHLCVBar> {
let mut out: Vec<OHLCVBar> = Vec::with_capacity(bars.len());
let mut prev_close: Option<f64> = None;
for bar in bars {
// Per-bar invariants
if !(bar.open.is_finite() && bar.high.is_finite() && bar.low.is_finite() && bar.close.is_finite()) {
warn!("sanitize: drop non-finite OHLC at {}", bar.timestamp);
continue;
}
if bar.open <= 0.0 || bar.high <= 0.0 || bar.low <= 0.0 || bar.close <= 0.0 {
warn!("sanitize: drop non-positive OHLC at {} (o={} h={} l={} c={})",
bar.timestamp, bar.open, bar.high, bar.low, bar.close);
continue;
}
if bar.high < bar.low {
warn!("sanitize: drop high<low at {} (h={} l={})",
bar.timestamp, bar.high, bar.low);
continue;
}
// Cross-bar invariant: close moves within ±100% of previous close.
// Real markets stay well inside ±10% per bar; ±100% catches genuine
// corruption while not rejecting any legitimate move (futures gap
// limits are far below this).
if let Some(prev) = prev_close {
let ratio = bar.close / prev;
if !ratio.is_finite() || !(0.5..=2.0).contains(&ratio) {
warn!("sanitize: drop extreme close ratio at {} (close={} prev_close={} ratio={:.3e})",
bar.timestamp, bar.close, prev, ratio);
continue;
}
}
prev_close = Some(bar.close);
out.push(bar);
}
out
}
/// List subdirectory names for error messages.
pub fn list_subdirs(dir: &Path) -> Vec<String> {
std::fs::read_dir(dir)

View File

@@ -685,6 +685,12 @@ async fn main() -> Result<()> {
let features = norm_stats.normalize_batch(&features);
info!("Features z-score normalized ({} bars × 42 dims)", features.len());
// Defence-in-depth gate: never write a poisoned fxcache to disk. If anything
// upstream produced an out-of-bounds value, fail loudly here rather than
// letting it silently propagate to every future training run that reads
// this cache. Same gate the fxcache loader and DBN fallback enforce.
ml::walk_forward::validate_normalized_features(&features, "precompute_features writer")?;
// ── Write .fxcache ───────────────────────────────────────────────────────
std::fs::create_dir_all(&output_dir)
.with_context(|| format!("Failed to create output directory: {}", output_dir.display()))?;

View File

@@ -594,6 +594,11 @@ fn run_training(args: &Args) -> Result<Vec<RlTrainingResult>> {
let fxcache = if let Some(cached) = fxcache_data {
info!(" Loaded {} bars + features from fxcache in {:.1}s",
cached.bar_count, data_load_start.elapsed().as_secs_f64());
// Defence-in-depth: validate post-load even on the fast path. fxcache files
// can be stale (different code, different normalization stats, schema drift)
// and the cache_key + schema_hash check doesn't catch every corruption shape.
// Reject rather than upload poisoned values. Same gate the DBN fallback runs.
ml::walk_forward::validate_normalized_features(&cached.features, "fxcache load")?;
cached
} else {
// Fall back to DBN loading — this is SLOW (148GB MBP-10 parsing).
@@ -634,6 +639,13 @@ fn run_training(args: &Args) -> Result<Vec<RlTrainingResult>> {
info!(" Features z-score normalised (DBN-fallback path; {} bars × 42 dims)",
all_features.len());
// Defence-in-depth: same gate the fxcache path runs after load. Both
// paths must produce data that satisfies `|feat| ≤ NORMALIZED_FEATURE_BOUND`
// before it's allowed to flow to the GPU. NormStats::normalize already
// clamps each output, so this should always pass — failing here would
// indicate the clamp itself is broken.
ml::walk_forward::validate_normalized_features(&all_features, "DBN fallback")?;
// Build FxCacheData from DBN results (features are already warmup-trimmed)
let aligned_bars = &bars[warmup_offset..];
let n = all_features.len();

View File

@@ -534,12 +534,28 @@ impl FeatureExtractor {
Ok(())
}
/// Validate no NaN/Inf in feature vector
/// Validate feature vector: NaN/Inf rejection AND magnitude bound.
///
/// Pre-normalization features come from `safe_normalize` ([0, 1] or [-1, 1]),
/// `safe_clip` (max ±3 across all uses in this module), and `safe_log_return`
/// (clamped to ±0.1). The widest legitimate range is therefore ±3. Any value
/// beyond ±5 indicates either an extractor invariant break or a corrupt input
/// bar; reject the dataset rather than let it flow into z-score normalization
/// where it would inflate `std` and silently amplify into state[0] outliers.
fn validate_features(&self, features: &[f64]) -> Result<()> {
const PRE_NORM_BOUND: f64 = 5.0;
for (i, &val) in features.iter().enumerate() {
if !val.is_finite() {
anyhow::bail!("Invalid feature at index {}: {}", i, val);
}
if val.abs() > PRE_NORM_BOUND {
anyhow::bail!(
"Feature[{}] = {} exceeds [-{}, {}] pre-normalize guard — extractor \
invariant broken (corrupt bar, log-return clamp bypass, or normalization \
bug). Reject the dataset rather than poison the model.",
i, val, PRE_NORM_BOUND, PRE_NORM_BOUND
);
}
}
Ok(())
}
@@ -1242,7 +1258,21 @@ impl TechnicalIndicatorState {
// ===== Utility Functions =====
/// Safe log return: log(current / previous), handles edge cases
/// Safe log return: `log(current / previous)`, with structural bounds.
///
/// Why the result clamp: real-market 1-bar log returns rarely exceed ±0.05
/// (5% per bar on NQ futures is already an extreme move). A corrupt input
/// (DBN parse glitch, bar.open ≈ 0, broker tick error) can produce log
/// returns of 20 or worse; without the clamp these flow through z-score
/// normalization and end up as state[0] outliers in the 30000 magnitude
/// range, which silently poisons every downstream consumer (aux head label
/// scale, Q values, reward EMA). Hard-clamping at ±0.1 (~10%) traps every
/// realistic move while rejecting all known corruption shapes.
///
/// Edge-case fallthroughs (return 0.0): non-positive inputs, non-finite
/// ratio. The clamp is the last line of defense after the input guards.
pub(crate) const SAFE_LOG_RETURN_CLAMP: f64 = 0.1;
fn safe_log_return(current: f64, previous: f64) -> f64 {
if previous <= 0.0 || current <= 0.0 {
return 0.0;
@@ -1251,7 +1281,7 @@ fn safe_log_return(current: f64, previous: f64) -> f64 {
if ratio <= 0.0 || !ratio.is_finite() {
return 0.0;
}
ratio.ln()
ratio.ln().clamp(-SAFE_LOG_RETURN_CLAMP, SAFE_LOG_RETURN_CLAMP)
}
/// Safe normalization: (value - min) / (max - min), clipped to [0, 1]

View File

@@ -1782,136 +1782,14 @@ impl DQNTrainer {
"GPU zero-roundtrip collection FAILED (no CPU fallback): {e}"
))?;
// ── DIAG: pin Bug 2 (state[0]=raw_close vs log_return) ─────────────
// One-shot dump of first 5 samples × 5 cols of next_states + states.
// Goal: determine whether the rollout produces normalized log_return
// or raw price at state[0]. Compare against fxcache feat[0] stddev=1.0
// and aux label_scale=5300 (production observed value pre-fix).
// Triggers once via static AtomicBool. f32 dtoh download — slow but
// pre-graph-capture, no perf impact since it only fires step 1.
{
use std::sync::atomic::{AtomicBool, Ordering};
static DUMPED: AtomicBool = AtomicBool::new(false);
if !DUMPED.swap(true, Ordering::AcqRel) {
if let Some(ref stream) = self.cuda_stream {
let sd = gpu_batch.state_dim;
let n_dump = 5_usize.min(gpu_batch.n_episodes * gpu_batch.timesteps * 2);
let n_cols = 5_usize.min(sd);
// Pre-graph-capture one-shot DtoH download — the diagnostic
// is fine to use the slow path; it fires once per process.
// `clone_dtoh` replaces the deprecated `memcpy_dtov`.
let host_states: Vec<f32> = stream
.clone_dtoh(&gpu_batch.states)
.unwrap_or_default();
let host_next: Vec<f32> = stream
.clone_dtoh(&gpu_batch.next_states)
.unwrap_or_default();
if !host_states.is_empty() && !host_next.is_empty() {
// Per-sample first-5-col rows.
for i in 0..n_dump {
let s_off = i * sd;
let s_row: Vec<f32> = (0..n_cols)
.map(|j| host_states.get(s_off + j).copied().unwrap_or(f32::NAN))
.collect();
let n_row: Vec<f32> = (0..n_cols)
.map(|j| host_next.get(s_off + j).copied().unwrap_or(f32::NAN))
.collect();
tracing::warn!(
target: "DIAG_BUG2",
"sample[{i}] state[0..{n_cols}]={s_row:?} next_state[0..{n_cols}]={n_row:?}"
);
}
// Mean abs at col 0 across all samples — direct EMA approximation.
let n_total = host_next.len() / sd;
let sum_abs_s: f64 = (0..n_total).map(|i| host_states[i * sd].abs() as f64).sum();
let sum_abs_n: f64 = (0..n_total).map(|i| host_next[i * sd].abs() as f64).sum();
let mean_abs_s = sum_abs_s / (n_total.max(1) as f64);
let mean_abs_n = sum_abs_n / (n_total.max(1) as f64);
// Stddev too — quick pass.
let mean_s: f64 = (0..n_total).map(|i| host_states[i * sd] as f64).sum::<f64>() / (n_total.max(1) as f64);
let mean_n: f64 = (0..n_total).map(|i| host_next[i * sd] as f64).sum::<f64>() / (n_total.max(1) as f64);
let var_s: f64 = (0..n_total).map(|i| (host_states[i * sd] as f64 - mean_s).powi(2)).sum::<f64>() / (n_total.max(1) as f64);
let var_n: f64 = (0..n_total).map(|i| (host_next[i * sd] as f64 - mean_n).powi(2)).sum::<f64>() / (n_total.max(1) as f64);
tracing::warn!(
target: "DIAG_BUG2",
"col0 stats over {n_total} samples: states[mean={mean_s:.4e}, std={:.4e}, mean_abs={mean_abs_s:.4e}] next_states[mean={mean_n:.4e}, std={:.4e}, mean_abs={mean_abs_n:.4e}]",
var_s.sqrt(), var_n.sqrt()
);
tracing::warn!(
target: "DIAG_BUG2",
"interpretation: mean_abs ~0.001 = normalized log_return ✓ | mean_abs ~5000 = raw price ✗ (Bug 2 confirmed)"
);
// ── DIAG_BUG2_v2: outlier disambiguation ──────────────
// H1: fxcache feat[0] has raw-price values for some bars
// H2: state_gather reads garbage (wrong bar_idx / kernel bug)
// Scan both to disambiguate.
const OUTLIER_THRESHOLD: f32 = 100.0;
const MAX_REPORT: usize = 10;
// Outliers in gathered states[col0]
let mut state_outliers: Vec<(usize, f32)> = Vec::new();
for i in 0..n_total {
let v = host_states[i * sd];
if v.abs() > OUTLIER_THRESHOLD {
state_outliers.push((i, v));
if state_outliers.len() >= MAX_REPORT { break; }
}
}
let mut next_outliers: Vec<(usize, f32)> = Vec::new();
for i in 0..(host_next.len() / sd) {
let v = host_next[i * sd];
if v.abs() > OUTLIER_THRESHOLD {
next_outliers.push((i, v));
if next_outliers.len() >= MAX_REPORT { break; }
}
}
tracing::warn!(
target: "DIAG_BUG2",
"v2 state[0] outliers (|x|>{OUTLIER_THRESHOLD}): {state_outliers:?}"
);
tracing::warn!(
target: "DIAG_BUG2",
"v2 next_state[0] outliers (|x|>{OUTLIER_THRESHOLD}): {next_outliers:?}"
);
// Outliers in source features_raw_cuda[bar*42 + 0]
// host_ptr aliases the same memory the kernel reads — no DtoH needed.
let market_dim_src: usize = 42;
let n_bars = features_buf.len / market_dim_src;
let mut feat_outliers: Vec<(usize, f32)> = Vec::new();
let mut feat_max_abs = 0.0f32;
let mut feat_sum_sq: f64 = 0.0;
let mut feat_sum: f64 = 0.0;
let scan_n = n_bars.min(2_000_000); // 2M cap for safety
for b in 0..scan_n {
let v = unsafe { *features_buf.host_ptr.add(b * market_dim_src) };
feat_sum += v as f64;
feat_sum_sq += (v as f64) * (v as f64);
if v.abs() > feat_max_abs { feat_max_abs = v.abs(); }
if v.abs() > OUTLIER_THRESHOLD && feat_outliers.len() < MAX_REPORT {
feat_outliers.push((b, v));
}
}
let feat_mean = feat_sum / scan_n.max(1) as f64;
let feat_var = (feat_sum_sq / scan_n.max(1) as f64) - feat_mean * feat_mean;
tracing::warn!(
target: "DIAG_BUG2",
"v2 fxcache feat[0] over {scan_n} bars: mean={feat_mean:.4e}, std={:.4e}, max_abs={feat_max_abs:.4e}",
feat_var.max(0.0).sqrt()
);
tracing::warn!(
target: "DIAG_BUG2",
"v2 fxcache feat[0] outliers (|x|>{OUTLIER_THRESHOLD}): {feat_outliers:?}"
);
tracing::warn!(
target: "DIAG_BUG2",
"v2 verdict: feat-outliers nonempty → H1 (fxcache corruption); state-outliers nonempty AND feat-outliers empty → H2 (kernel bug)"
);
}
}
}
}
// Bug 2 instrumentation removed — replaced by structural defense at the
// data-loading layer (`safe_log_return` clamp, `validate_features`
// pre-norm bound, `NormStats::normalize` post-norm clamp,
// `validate_normalized_features` post-load gate, and bar-level sanity
// in `load_all_bars`). The DIAG_BUG2 / DIAG_BUG2_v2 host-side downloads
// existed to pin the leak path; the structural guards now make it
// impossible for raw-price-magnitude values to reach state[0] in the
// first place. See `docs/dqn-gpu-hot-path-audit.md` Fix 25.
let count = gpu_batch.n_episodes * gpu_batch.timesteps * 2; // counterfactual doubles experiences

View File

@@ -631,6 +631,16 @@ const FEATURE_DIM: usize = 42;
/// Minimum standard deviation to prevent division by zero.
const MIN_STD: f64 = 1e-8;
/// Maximum legitimate magnitude of a z-score-normalized feature.
///
/// Z-scores in real-market 42-dim feature vectors live well within ±5 even on
/// extreme bars (10σ events are vanishingly rare); ±20 is a generous ceiling
/// that traps every observed corruption pattern (raw-price leak, corrupt bar
/// with log_return = -30 / std=1e-3 = -30000, etc.) without rejecting any
/// legitimate market signal. Used as the shared invariant in
/// [`validate_normalized_features`].
pub const NORMALIZED_FEATURE_BOUND: f64 = 20.0;
impl NormStats {
/// Compute normalization statistics from a batch of feature vectors (FEATURE_DIM dimensions).
///
@@ -682,9 +692,16 @@ impl NormStats {
}
}
/// Z-score normalize a single 51-dimensional feature vector.
/// Z-score normalize a single 42-dimensional feature vector.
///
/// Returns `(feature - mean) / std` element-wise.
/// Returns `clamp((feature - mean) / std, ±NORMALIZED_FEATURE_BOUND)`
/// element-wise. The clamp is the structural guarantee that downstream GPU
/// consumers (state_gather → state[0] in `experience_state_gather`, aux
/// label_scale_ema, vol_normalizer) see bounded inputs regardless of what
/// corruption survives upstream. Without it, a single outlier from a
/// corrupt DBN bar inflates `std` enough to amplify itself by sqrt(N) post-
/// normalization and flow through training as a ±30000 magnitude state[0]
/// outlier.
pub fn normalize(&self, features: &[f64; FEATURE_DIM]) -> [f64; FEATURE_DIM] {
let mut result = [0.0_f64; FEATURE_DIM];
for ((r, val), (m, s)) in result
@@ -692,17 +709,54 @@ impl NormStats {
.zip(features.iter())
.zip(self.mean.iter().zip(self.std.iter()))
{
*r = (val - m) / s;
*r = ((val - m) / s).clamp(-NORMALIZED_FEATURE_BOUND, NORMALIZED_FEATURE_BOUND);
}
result
}
/// Z-score normalize a batch of 51-dimensional feature vectors.
/// Z-score normalize a batch of 42-dimensional feature vectors.
pub fn normalize_batch(&self, features: &[[f64; FEATURE_DIM]]) -> Vec<[f64; FEATURE_DIM]> {
features.iter().map(|f| self.normalize(f)).collect()
}
}
/// Validate that a batch of normalized features is bounded and free of NaN/Inf.
///
/// Single source of truth for the post-normalization invariant — used by both
/// the fxcache fast path (after `discover_and_load`) and the DBN fallback
/// (after `normalize_batch`). Either path producing data that fails this gate
/// must error out rather than upload poisoned values to the GPU.
///
/// Returns `Ok(())` if every element of every vector is finite and within
/// `±NORMALIZED_FEATURE_BOUND`. On the first violation, returns an `Err` with
/// the bar index, feature index, and value so the operator can locate the
/// corrupt source row.
pub fn validate_normalized_features(
features: &[[f64; FEATURE_DIM]],
source: &str,
) -> anyhow::Result<()> {
for (bar_idx, feat_vec) in features.iter().enumerate() {
for (feat_idx, &val) in feat_vec.iter().enumerate() {
if !val.is_finite() {
anyhow::bail!(
"{source}: non-finite value at bar={bar_idx}, feat={feat_idx}: {val} \
— data corruption between source and post-normalization."
);
}
if val.abs() > NORMALIZED_FEATURE_BOUND {
anyhow::bail!(
"{source}: |feature[{bar_idx}][{feat_idx}]| = {} exceeds normalized bound \
(±{NORMALIZED_FEATURE_BOUND}) — corrupt bar (e.g. bar.open ≈ 0 producing \
extreme log return) or unit-mismatch (raw price written instead of \
z-normalized log return). Reject the dataset; do not upload to GPU.",
val
);
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -315,6 +315,37 @@ Final structural sweep that closes the residual `*_via_pinned` ctor / cold-path
**Out of scope.** `mapped_pinned.rs` retains the `upload_f32_via_pinned` / `upload_i32_via_pinned` / `clone_to_device_f32_via_pinned` / `clone_to_device_i32_via_pinned` helper definitions — `trainers/ppo.rs` and a small set of remaining cold-path callers still depend on them; the helpers are deleted under task #290 once the call count globally hits zero. Per `feedback_no_hiding` no suppression markers were added.
### Fix 25 — Structural data-loading defense + DIAG_BUG2 cleanup (2026-05-02)
**Goal: stop chasing one-off data-corruption bugs. Make corruption impossible to flow into training, regardless of source.**
After three+ historical fixes for data-loading corruption (#191 fxcache column-0 raw-price leak, Bug 1 fxcache target-column semantics, label_scale=5443 raw-magnitude leaks, plus today's state[0] heavy-tail with std=570 outliers), the structural defense was still missing. Each prior fix patched a specific writer; the next corruption shape always found a new hole. This commit installs a 5-layer defense-in-depth gate stack so any future corruption — whatever its origin — is rejected at the data-loading boundary instead of poisoning the model.
**The 5 layers (each independent, each mandatory):**
1. **Bar-level sanity** (`baseline_common/mod.rs::sanitize_bars`): drops bars with non-positive OHLC, `high < low`, non-finite values, or `bar.close / prev.close` outside `[0.5, 2.0]`. Catches DBN parse glitches, broker tick errors, near-zero-open bars at source. Logs each drop with timestamp.
2. **`safe_log_return` result clamp** (`features/extraction.rs:1246`): `ratio.ln().clamp(±0.1)`. Real-market 1-bar log returns rarely exceed ±0.05; the ±0.1 ceiling traps every realistic move while rejecting all known corruption shapes (a corrupt bar with `bar.open ≈ 0` would otherwise produce `ln = -30` and flow through z-score normalization to ±30000-magnitude state[0] outliers).
3. **`validate_features` pre-norm magnitude bound** (`features/extraction.rs::validate_features`): post-extraction, every feature must satisfy `|val| ≤ 5.0`. Pre-normalization features come from `safe_normalize` ([0, 1] / [-1, 1]), `safe_clip` (max ±3 across all uses), or clamped log returns (±0.1); the widest legitimate range is ±3, so ±5 catches extractor invariant breaks without rejecting any legitimate output.
4. **`NormStats::normalize` post-norm clamp** (`walk_forward.rs:688`): `((val - mean) / std).clamp(±NORMALIZED_FEATURE_BOUND)` with bound = 20.0. Even if upstream layers somehow produce outliers (logic bug, future regression), this guarantees every value uploaded to GPU is within ±20.0 — well above any legitimate z-scored signal but well below the magnitudes (±30000) that previously poisoned label_scale_ema.
5. **Shared `validate_normalized_features` gate** (`walk_forward.rs::validate_normalized_features`): single source-of-truth invariant, enforced at three call sites:
- **fxcache fast path**: after `discover_and_load` returns `Some(cached)` in `train_baseline_rl.rs:594`. fxcache files can be stale (different code, schema drift, manual file edits) — the cache_key + schema_hash check doesn't catch every corruption shape. If load returns out-of-bounds data, fail rather than upload it.
- **DBN fallback**: after `normalize_batch` in `train_baseline_rl.rs:633`. NormStats::normalize already clamps each output, so failure here means the clamp itself is broken.
- **precompute writer**: after `normalize_batch` in `precompute_features.rs:686`. Never write a poisoned fxcache to disk; failing here prevents corruption from being persisted across runs.
**Removed:** `DIAG_BUG2` and `DIAG_BUG2_v2` one-shot diagnostic blocks at `training_loop.rs:1785-1908` (~125 lines, including DtoH downloads + outlier scans). Replaced by the structural defense — instrumentation isn't needed once corruption can't reach state[0] in the first place. Same commit also removes the `pre-norm` and `post-norm` interpretive log lines that referenced the now-impossible "raw-price magnitude" failure mode.
**FEATURE_SCHEMA_HASH auto-bumps** (build.rs FNV-1a hash over SCHEMA_FILES, includes `extraction.rs`). This invalidates any pre-fix `.fxcache` file via the strict header check at `fxcache.rs:201`. The next training run will trigger ensure-fxcache regen, producing a clean cache with the new clamps applied.
**Why this finally closes the chapter:**
- Per-writer fixes are reactive (after-the-fact, after corruption has already hit production).
- A boundary-validation invariant is proactive — the contract becomes "any data path that violates `|feat| ≤ 20.0` post-normalize panics at the boundary." Every future regression to extraction or normalization is caught at the gate, not at epoch 5 of a 50-epoch L40S run.
- The 5 layers are independent: a bug in one layer leaves the others as backstop. To produce a state[0] outlier under this commit, every single one of {sanitize_bars, safe_log_return clamp, validate_features pre-bound, NormStats clamp, validate_normalized_features gate} would have to silently fail simultaneously.
**Validation.** `cargo check -p ml --all-targets --offline` clean. Unit tests for NormStats (walk_forward.rs:706+) still pass — the clamp and the new validate function are additive; existing test inputs are well within bounds.
### Fix 24 — DIAG_BUG2_v2 outlier disambiguation (H1 vs H2) (2026-05-02)
Augments the one-shot DIAG_BUG2 block at `training_loop.rs:1785` with two narrower scans that disambiguate where the state[0] heavy-tail originates. Smoke `smoke-test-xb78r` at HEAD `cba9f25ed` reported `mean=6.0e-3, std=5.7e2, mean_abs=2.0e1` over 3200 samples but printed first-5-sample state[0..5] values that all sit in [-1, 1] — implies ~1-2 of 3200 samples carry ±32000-magnitude values rather than uniform corruption.