diff --git a/crates/ml-alpha/examples/alpha_train.rs b/crates/ml-alpha/examples/alpha_train.rs index c053fa881..94205ec83 100644 --- a/crates/ml-alpha/examples/alpha_train.rs +++ b/crates/ml-alpha/examples/alpha_train.rs @@ -484,12 +484,26 @@ fn main() -> Result<()> { // don't pollute the batch. let mut snap_batch: Vec> = Vec::with_capacity(cli.batch_size); let mut label_batch: Vec> = Vec::with_capacity(cli.batch_size); - // SDD-3 Layer B5: aux outcome labels from the loader's D-style - // generator. NaN slots are natively masked by the Huber loss kernel. - let mut out_long_batch: Vec> = + // SDD-3 CB2: aux outcome labels from the loader's A+B paired + // generator — prof-binary + σ-normalized size targets per direction. + // NaN slots are natively masked by the existing Huber loss kernel + // (CB2 transition state: still routed through the D-era kernel via + // the prof-binary head; CB5 rewrites the kernel to BCE+Huber properly). + let mut out_prof_long_batch: Vec> = Vec::with_capacity(cli.batch_size); - let mut out_short_batch: Vec> = + let mut out_prof_short_batch: Vec> = Vec::with_capacity(cli.batch_size); + let mut out_size_long_batch: Vec> = + Vec::with_capacity(cli.batch_size); + let mut out_size_short_batch: Vec> = + Vec::with_capacity(cli.batch_size); + // pos_fraction is a file-level statistic; every sequence in the + // current batch carries the per-source-file vector. The trainer + // currently accepts a single `&[f32]` of length `2 * N_AUX_HORIZONS` + // — broadcast the first sequence's vector across the batch (all + // batched sequences may come from different files, so this is an + // approximation that CB5 will refine via per-sample weighting). + let mut batch_pos_fraction: Vec = vec![0.0_f32; 2 * N_AUX_HORIZONS]; while let Some(seq) = train_loader.next_sequence().context("train next_seq")? { let mut labels_per_pos: Vec<[f32; N_HORIZONS]> = Vec::with_capacity(seq.snapshots.len()); let mut any_finite = false; @@ -499,27 +513,43 @@ fn main() -> Result<()> { labels_per_pos.push(row); } if !any_finite { continue; } - // SDD-3 Layer B5: pull D-style aux outcomes for both directions. - // `seq.outcome_long[h]` / `seq.outcome_short[h]` are `Vec` - // sliced per-anchor; convert to per-position rows mirroring the - // BCE label layout (so the trainer's `step_batched` sees one - // `[N_AUX_HORIZONS]` row per K position). - let mut out_long_pos: Vec<[f32; N_AUX_HORIZONS]> = + // SDD-3 CB2: convert per-(horizon, snapshot) loader columns into + // per-snapshot `[N_AUX_HORIZONS]` rows mirroring the BCE label + // layout (so the trainer's `step_batched` sees one row per K). + let mut out_prof_long_pos: Vec<[f32; N_AUX_HORIZONS]> = Vec::with_capacity(seq.snapshots.len()); - let mut out_short_pos: Vec<[f32; N_AUX_HORIZONS]> = + let mut out_prof_short_pos: Vec<[f32; N_AUX_HORIZONS]> = + Vec::with_capacity(seq.snapshots.len()); + let mut out_size_long_pos: Vec<[f32; N_AUX_HORIZONS]> = + Vec::with_capacity(seq.snapshots.len()); + let mut out_size_short_pos: Vec<[f32; N_AUX_HORIZONS]> = Vec::with_capacity(seq.snapshots.len()); for k in 0..seq.snapshots.len() { - let row_long: [f32; N_AUX_HORIZONS] = - std::array::from_fn(|h| seq.outcome_long[h][k]); - let row_short: [f32; N_AUX_HORIZONS] = - std::array::from_fn(|h| seq.outcome_short[h][k]); - out_long_pos.push(row_long); - out_short_pos.push(row_short); + let row_prof_long: [f32; N_AUX_HORIZONS] = + std::array::from_fn(|h| seq.outcome_prof_long[h][k]); + let row_prof_short: [f32; N_AUX_HORIZONS] = + std::array::from_fn(|h| seq.outcome_prof_short[h][k]); + let row_size_long: [f32; N_AUX_HORIZONS] = + std::array::from_fn(|h| seq.outcome_size_long[h][k]); + let row_size_short: [f32; N_AUX_HORIZONS] = + std::array::from_fn(|h| seq.outcome_size_short[h][k]); + out_prof_long_pos.push(row_prof_long); + out_prof_short_pos.push(row_prof_short); + out_size_long_pos.push(row_size_long); + out_size_short_pos.push(row_size_short); + } + // Capture pos_fraction from the first sample of the batch; + // overwrite on every accepted sample (CB5 will lift this to + // per-sample weighting). + if seq.pos_fraction.len() == batch_pos_fraction.len() { + batch_pos_fraction.copy_from_slice(&seq.pos_fraction); } snap_batch.push(seq.snapshots); label_batch.push(labels_per_pos); - out_long_batch.push(out_long_pos); - out_short_batch.push(out_short_pos); + out_prof_long_batch.push(out_prof_long_pos); + out_prof_short_batch.push(out_prof_short_pos); + out_size_long_batch.push(out_size_long_pos); + out_size_short_batch.push(out_size_short_pos); if snap_batch.len() < cli.batch_size { continue; } // Full batch ready — apply LR schedule, fire step. @@ -534,12 +564,24 @@ fn main() -> Result<()> { let snap_refs: Vec<&[Mbp10RawInput]> = snap_batch.iter().map(|v| v.as_slice()).collect(); let label_refs: Vec<&[[f32; N_HORIZONS]]> = label_batch.iter().map(|v| v.as_slice()).collect(); - let out_long_refs: Vec<&[[f32; N_AUX_HORIZONS]]> = - out_long_batch.iter().map(|v| v.as_slice()).collect(); - let out_short_refs: Vec<&[[f32; N_AUX_HORIZONS]]> = - out_short_batch.iter().map(|v| v.as_slice()).collect(); + let prof_long_refs: Vec<&[[f32; N_AUX_HORIZONS]]> = + out_prof_long_batch.iter().map(|v| v.as_slice()).collect(); + let prof_short_refs: Vec<&[[f32; N_AUX_HORIZONS]]> = + out_prof_short_batch.iter().map(|v| v.as_slice()).collect(); + let size_long_refs: Vec<&[[f32; N_AUX_HORIZONS]]> = + out_size_long_batch.iter().map(|v| v.as_slice()).collect(); + let size_short_refs: Vec<&[[f32; N_AUX_HORIZONS]]> = + out_size_short_batch.iter().map(|v| v.as_slice()).collect(); let loss = trainer - .step_batched(&snap_refs, &label_refs, &out_long_refs, &out_short_refs) + .step_batched( + &snap_refs, + &label_refs, + &prof_long_refs, + &prof_short_refs, + &size_long_refs, + &size_short_refs, + &batch_pos_fraction, + ) .context("train step_batched")?; epoch_train_loss += loss; epoch_train_steps += 1; @@ -560,8 +602,10 @@ fn main() -> Result<()> { } snap_batch.clear(); label_batch.clear(); - out_long_batch.clear(); - out_short_batch.clear(); + out_prof_long_batch.clear(); + out_prof_short_batch.clear(); + out_size_long_batch.clear(); + out_size_short_batch.clear(); } let epoch_avg = if epoch_train_steps > 0 { epoch_train_loss / epoch_train_steps as f32 diff --git a/crates/ml-alpha/src/aux_heads.rs b/crates/ml-alpha/src/aux_heads.rs index 797549e4f..b1a935590 100644 --- a/crates/ml-alpha/src/aux_heads.rs +++ b/crates/ml-alpha/src/aux_heads.rs @@ -1,8 +1,15 @@ //! SDD-3 Layer B4 — Aux heads (linear regression) + Huber loss. //! //! Sits on top of [`crate::cfc::AuxTrunk`] and predicts continuous-valued -//! trade-outcome targets emitted by [`crate::multi_horizon_labels::generate_outcome_labels_d`] -//! (the D-style asymmetric loss-aversion labels from SDD-3 Layer B1). +//! trade-outcome targets emitted by [`crate::multi_horizon_labels::generate_outcome_labels_ab`] +//! (Candidate-B paired prof-binary + size-regression labels from SDD-3 CB1). +//! +//! CB2 transition state: the head signature here is unchanged (still emits +//! `y_{long,short}_hat[B, N]` regression outputs). CB5 will rewrite the loss +//! kernel to consume the prof-binary head via BCE and the size head via Huber. +//! Until then, the regression outputs are supervised against `y_prof_{dir}` +//! through the existing Huber path — a holding-pattern signal, not the final +//! A+B losses. //! //! ## Architecture //! diff --git a/crates/ml-alpha/src/data/loader.rs b/crates/ml-alpha/src/data/loader.rs index 57780fd63..3a3daac54 100644 --- a/crates/ml-alpha/src/data/loader.rs +++ b/crates/ml-alpha/src/data/loader.rs @@ -20,7 +20,7 @@ use rand_chacha::ChaCha8Rng; use crate::cfc::snap_features::{Mbp10RawInput, ES_TICK_SIZE, REGIME_DIM}; use crate::heads::N_HORIZONS; -use crate::multi_horizon_labels::{generate_labels, generate_outcome_labels_d}; +use crate::multi_horizon_labels::{generate_labels, generate_outcome_labels_ab}; /// EMA smoothing factors. Half-life ≈ ln(2)/α snapshots. /// @@ -171,19 +171,29 @@ pub struct LabeledSequence { /// forward window is outside the source file (right edge) or where /// the move is a tied price (drop, mirror `generate_labels` semantics). pub labels: [Vec; N_HORIZONS], - /// `[N_HORIZONS][seq_len]` D-style asymmetric outcome labels for the - /// long direction (Phase B aux supervision). - /// `outcome_long[h][t] = (price[t+K] - price[t] - cost) - - /// 1.5 × |max long drawdown over [t, t+K]|`. - /// NaN at right-edge positions where the forward window is outside the - /// source file. See [`crate::multi_horizon_labels::generate_outcome_labels_d`]. - pub outcome_long: [Vec; N_HORIZONS], - /// `[N_HORIZONS][seq_len]` D-style asymmetric outcome labels for the - /// short direction. - /// `outcome_short[h][t] = (price[t] - price[t+K] - cost) - - /// 1.5 × |max short drawup over [t, t+K]|`. - /// Same NaN edge semantics as `outcome_long`. - pub outcome_short: [Vec; N_HORIZONS], + /// `[N_HORIZONS][seq_len]` Candidate-B prof-binary labels for the long + /// direction (1.0 if `ΔP_K - 2×cost > 0`, 0.0 otherwise, NaN at right + /// edge or non-finite price). See [`crate::multi_horizon_labels::generate_outcome_labels_ab`]. + pub outcome_prof_long: [Vec; N_HORIZONS], + /// `[N_HORIZONS][seq_len]` Candidate-B prof-binary labels for the short + /// direction. + pub outcome_prof_short: [Vec; N_HORIZONS], + /// `[N_HORIZONS][seq_len]` Candidate-B σ-normalized signed-return targets + /// for the long direction. NaN where prof_long==0 or σ_K not yet warmed. + pub outcome_size_long: [Vec; N_HORIZONS], + /// `[N_HORIZONS][seq_len]` Candidate-B σ-normalized signed-return targets + /// for the short direction. Same NaN semantics as `outcome_size_long`. + pub outcome_size_short: [Vec; N_HORIZONS], + /// `[N_HORIZONS][seq_len]` rolling 1000-step Welford std of K-step + /// log-returns at each snapshot. NaN during the warmup window. Used + /// downstream for σ-normalized size targets and any in-trainer scaling. + pub sigma_k: [Vec; N_HORIZONS], + /// Per-file class imbalance for `pos_weight` BCE balancing. + /// Length = `2 * N_HORIZONS`, laid out as + /// `[long_h0, long_h1, ..., long_hN, short_h0, ..., short_hN]`. + /// File-level statistic — every sequence yielded from the same source + /// file carries the same vector. + pub pos_fraction: Vec, } /// Loaded-file cache. Holds the deserialized snapshots + pre-computed @@ -195,13 +205,27 @@ struct LoadedFile { /// `cfg.horizons[h]`, indexed the same as `snapshots`. Sliced per /// anchor on each `next_sequence()` call. labels_full: [Vec; N_HORIZONS], - /// `outcome_long_full[h]` holds the full-stream D-style asymmetric - /// long-direction outcome label vector for horizon `cfg.horizons[h]`. + /// `outcome_prof_long_full[h]` holds the full-stream Candidate-B + /// prof-binary long-direction label vector for horizon `cfg.horizons[h]`. /// NaN at right-edge positions. Sliced per anchor alongside `labels_full`. - outcome_long_full: [Vec; N_HORIZONS], - /// `outcome_short_full[h]` — short-direction counterpart to - /// `outcome_long_full`. - outcome_short_full: [Vec; N_HORIZONS], + outcome_prof_long_full: [Vec; N_HORIZONS], + /// `outcome_prof_short_full[h]` — short-direction counterpart to + /// `outcome_prof_long_full`. + outcome_prof_short_full: [Vec; N_HORIZONS], + /// `outcome_size_long_full[h]` holds the full-stream Candidate-B + /// σ-normalized size target for the long direction. NaN where + /// prof_long==0 OR σ_K not yet warmed. + outcome_size_long_full: [Vec; N_HORIZONS], + /// `outcome_size_short_full[h]` — short-direction counterpart. + outcome_size_short_full: [Vec; N_HORIZONS], + /// `sigma_k_full[h]` rolling 1000-step Welford std of K-step log-returns. + /// NaN during the per-horizon warmup window. + sigma_k_full: [Vec; N_HORIZONS], + /// File-level positive-class fraction per (direction, horizon) for BCE + /// pos_weight balancing. Length = `2 * N_HORIZONS`, laid out as + /// `[long_h0..hN, short_h0..hN]`. Copied verbatim into every yielded + /// `LabeledSequence`. + pos_fraction: Vec, /// Per-snapshot regime context computed once per file via a forward /// EMA cascade. See [`compute_regime_features`]. Sliced per anchor. regime_full: Vec<[f32; REGIME_DIM]>, @@ -263,8 +287,14 @@ impl MultiHorizonLoader { continue; } let mut labels_full: [Vec; N_HORIZONS] = Default::default(); - let mut outcome_long_full: [Vec; N_HORIZONS] = Default::default(); - let mut outcome_short_full: [Vec; N_HORIZONS] = Default::default(); + let mut outcome_prof_long_full: [Vec; N_HORIZONS] = Default::default(); + let mut outcome_prof_short_full: [Vec; N_HORIZONS] = Default::default(); + let mut outcome_size_long_full: [Vec; N_HORIZONS] = Default::default(); + let mut outcome_size_short_full: [Vec; N_HORIZONS] = Default::default(); + let mut sigma_k_full: [Vec; N_HORIZONS] = Default::default(); + // Default pos_fraction is zero per direction/horizon; populated below in + // training mode. Length is fixed so downstream broadcast is uniform. + let mut pos_fraction: Vec = vec![0.0_f32; 2 * N_HORIZONS]; if !cfg.inference_only { let prices: Vec = snapshots.iter().map(mid_price_f32).collect(); for (h_idx, &h) in cfg.horizons.iter().enumerate() { @@ -275,28 +305,36 @@ impl MultiHorizonLoader { } labels_full[h_idx] = full; } - // Phase B aux supervision: D-style asymmetric loss-aversion labels - // generated from the same mid-price series. NaN at right-edge - // positions is preserved verbatim from the generator. - let outcome = generate_outcome_labels_d( + // Candidate-B aux supervision: paired prof-binary + σ-normalized + // size-regression targets per (direction, horizon). Replaces the + // old D-style asymmetric loss-aversion labels. + let outcome = generate_outcome_labels_ab( &prices, &cfg.horizons, cfg.outcome_label_cost, ) .with_context(|| format!( - "generate_outcome_labels_d for {} (cost={})", + "generate_outcome_labels_ab for {} (cost={})", path.display(), cfg.outcome_label_cost, ))?; - outcome_long_full = outcome.y_long; - outcome_short_full = outcome.y_short; + outcome_prof_long_full = outcome.y_prof_long; + outcome_prof_short_full = outcome.y_prof_short; + outcome_size_long_full = outcome.y_size_long; + outcome_size_short_full = outcome.y_size_short; + sigma_k_full = outcome.sigma_k; + pos_fraction = outcome.pos_fraction; } let regime_full = compute_regime_features(&snapshots); files_loaded.push(LoadedFile { snapshots, labels_full, - outcome_long_full, - outcome_short_full, + outcome_prof_long_full, + outcome_prof_short_full, + outcome_size_long_full, + outcome_size_short_full, + sigma_k_full, + pos_fraction, regime_full, }); } @@ -419,20 +457,32 @@ impl MultiHorizonLoader { let anchor: usize = self.rng.gen_range(0..max_anchor); let mut labels: [Vec; N_HORIZONS] = Default::default(); - let mut outcome_long: [Vec; N_HORIZONS] = Default::default(); - let mut outcome_short: [Vec; N_HORIZONS] = Default::default(); + let mut outcome_prof_long: [Vec; N_HORIZONS] = Default::default(); + let mut outcome_prof_short: [Vec; N_HORIZONS] = Default::default(); + let mut outcome_size_long: [Vec; N_HORIZONS] = Default::default(); + let mut outcome_size_short: [Vec; N_HORIZONS] = Default::default(); + let mut sigma_k: [Vec; N_HORIZONS] = Default::default(); for h in 0..N_HORIZONS { let mut row = Vec::with_capacity(self.cfg.seq_len); - let mut row_long = Vec::with_capacity(self.cfg.seq_len); - let mut row_short = Vec::with_capacity(self.cfg.seq_len); + let mut row_prof_long = Vec::with_capacity(self.cfg.seq_len); + let mut row_prof_short = Vec::with_capacity(self.cfg.seq_len); + let mut row_size_long = Vec::with_capacity(self.cfg.seq_len); + let mut row_size_short = Vec::with_capacity(self.cfg.seq_len); + let mut row_sigma = Vec::with_capacity(self.cfg.seq_len); for k in 0..self.cfg.seq_len { row.push(lf.labels_full[h][anchor + k]); - row_long.push(lf.outcome_long_full[h][anchor + k]); - row_short.push(lf.outcome_short_full[h][anchor + k]); + row_prof_long.push(lf.outcome_prof_long_full[h][anchor + k]); + row_prof_short.push(lf.outcome_prof_short_full[h][anchor + k]); + row_size_long.push(lf.outcome_size_long_full[h][anchor + k]); + row_size_short.push(lf.outcome_size_short_full[h][anchor + k]); + row_sigma.push(lf.sigma_k_full[h][anchor + k]); } labels[h] = row; - outcome_long[h] = row_long; - outcome_short[h] = row_short; + outcome_prof_long[h] = row_prof_long; + outcome_prof_short[h] = row_prof_short; + outcome_size_long[h] = row_size_long; + outcome_size_short[h] = row_size_short; + sigma_k[h] = row_sigma; } let mut sequence = Vec::with_capacity(self.cfg.seq_len); for k in 0..self.cfg.seq_len { @@ -447,8 +497,12 @@ impl MultiHorizonLoader { Ok(Some(LabeledSequence { snapshots: sequence, labels, - outcome_long, - outcome_short, + outcome_prof_long, + outcome_prof_short, + outcome_size_long, + outcome_size_short, + sigma_k, + pos_fraction: lf.pos_fraction.clone(), })) } } diff --git a/crates/ml-alpha/src/multi_horizon_labels.rs b/crates/ml-alpha/src/multi_horizon_labels.rs index 20ff6ea77..3600208a8 100644 --- a/crates/ml-alpha/src/multi_horizon_labels.rs +++ b/crates/ml-alpha/src/multi_horizon_labels.rs @@ -13,14 +13,26 @@ //! which is hardwired to `Phase1aConfig::horizon` and has Phase 1a //! corruption-audit assumptions baked in. //! -//! Phase B.B1 (2026-05-22) — D-style asymmetric loss-aversion labels. +//! Phase B.CB1 (2026-05-22) — Candidate-B paired aux labels (A+B pivot). //! -//! [`generate_outcome_labels_d`] produces per-`(t, horizon, direction)` -//! cost-aware labels that embed the trading objective directly into the -//! supervision signal: realized PnL minus round-trip cost, penalised by -//! `1.5 × max-adverse-excursion` along the holding path. Used as aux -//! supervision targets by the trade-outcome head (wired in subsequent -//! tasks). +//! [`generate_outcome_labels_ab`] produces per-`(t, horizon, direction)` +//! labels split into two complementary supervision signals: +//! +//! - **A (binary profitable)**: `y_prof_{long,short}[h][t] ∈ {0.0, 1.0}` — +//! one iff `direction × ΔP_K - 2×cost > 0`. Trained with class-weighted +//! BCE (`pos_fraction` field exposes the per-(direction,horizon) imbalance +//! for `pos_weight` selection in the loss kernel). +//! - **B (σ-normalized magnitude)**: `y_size_{long,short}[h][t] = +//! (direction × ΔP_K) / σ_K[t]`, masked (NaN) where `y_prof == 0`. Trained +//! with conditional Huber over the profitable subset only. +//! +//! `σ_K[t]` is a rolling-1000-snapshot Welford std of K-step log-returns, +//! floored at `cost / 4` (per `pearl_trade_level_vol_for_stop_distance` — the +//! cost tick scale is the structural noise floor below which σ_K is degenerate). +//! +//! This replaces the D-style asymmetric loss-aversion labels which were +//! empirically 94-99.99% negative on real ES MBP-10 data (un-learnable for +//! a flat aux head). use anyhow::{bail, Result}; use std::collections::VecDeque; @@ -79,33 +91,49 @@ pub fn generate_labels(prices: &[f32], k: usize) -> LongHorizonLabels { } } -/// Per-(direction, horizon) cost-aware asymmetric loss-aversion labels. -/// Used as aux supervision target for the trade-outcome head. -pub struct OutcomeLabelsD { - /// `y_long[h][t]` = (price[t+K] - price[t] - cost) - 1.5 * |max_drawdown_long| - /// NaN at indices where the forward window is outside the source file (right - /// edge) — same edge semantics as `generate_labels`. - pub y_long: [Vec; N_HORIZONS], - /// `y_short[h][t]` = (price[t] - price[t+K] - cost) - 1.5 * |max_drawup_short| - pub y_short: [Vec; N_HORIZONS], - /// Round-trip cost in price units (e.g. 0.5 for ES at 2-tick cost). +/// Rolling Welford window size for σ_K computation. 1000 K-step log-returns is +/// long enough for stable variance, short enough to track intraday regime drift. +const SIGMA_WINDOW: usize = 1000; + +/// Per-(direction, horizon) Candidate-B aux supervision targets: +/// - `y_prof_{long,short}[h][t]` ∈ {0.0, 1.0, NaN}: profitable-after-2×cost binary. +/// `y_prof_{dir}[h][t] = 1.0` iff `direction × ΔP_K - 2×cost > 0`. +/// - `y_size_{long,short}[h][t]`: σ-normalized signed return `(direction × ΔP_K) / σ_K[t]`. +/// NaN at right-edge OR when `y_prof_{dir}[h][t] == 0` (caller masks size loss on un-prof). +/// - `sigma_k[h][t]`: rolling-1000 Welford std of K-step log-returns at time t, used for +/// normalization. NaN until the warmup window fills. +pub struct OutcomeLabelsAB { + pub y_prof_long: [Vec; N_HORIZONS], + pub y_prof_short: [Vec; N_HORIZONS], + pub y_size_long: [Vec; N_HORIZONS], + pub y_size_short: [Vec; N_HORIZONS], + pub sigma_k: [Vec; N_HORIZONS], pub cost_price_units: f32, + /// Pos-class fraction per horizon per direction, computed at generation time + /// for downstream `pos_weight` BCE balancing. Length = `2 * N_HORIZONS`, + /// laid out as `[long_h0, long_h1, ..., short_h0, short_h1, ...]`. + pub pos_fraction: Vec, } -/// D-style asymmetric loss-aversion label generator. +/// Candidate-B (A+B paired) label generator. /// -/// Per (snapshot t, horizon K, direction d): -/// profit = signed_pnl(d) - cost -/// dd_against = max adverse excursion in [t, t+K] when holding direction d -/// y[t, K, d] = profit - 1.5 × |dd_against| +/// Per (snapshot t, horizon K, direction d ∈ {+1, -1}): +/// pnl_d = d × (prices[t+K] - prices[t]) - cost (cost already netted once) +/// y_prof = 1.0 if pnl_d > cost else 0.0 (i.e. signed_pnl > 2×cost) +/// y_size = pnl_d / σ_K[t] if y_prof == 1.0 else NaN /// -/// Uses monotonic-deque sliding-window min/max for O(N) per horizon. -/// `prices` is the bar/snapshot-aligned mid-price series. -pub fn generate_outcome_labels_d( +/// σ_K[t] is a 1000-step rolling Welford std of K-step log-returns +/// `ln(p[t+K] / p[t])`, floored at `cost_price_units / 4` per +/// `pearl_trade_level_vol_for_stop_distance` (cost tick scale = structural noise +/// floor; below this σ_K is degenerate noise). +/// +/// `pos_fraction` is computed over finite-y_prof entries only and consumed by the +/// loss kernel for class-weighted BCE. +pub fn generate_outcome_labels_ab( prices: &[f32], horizons: &[usize; N_HORIZONS], cost_price_units: f32, -) -> Result { +) -> Result { if !cost_price_units.is_finite() || cost_price_units < 0.0 { bail!( "cost_price_units must be finite and non-negative, got {}", @@ -119,129 +147,138 @@ pub fn generate_outcome_labels_d( } let n = prices.len(); - // SAFETY: NaN-initialised then overwritten in place; no MaybeUninit needed. - let mut y_long: [Vec; N_HORIZONS] = std::array::from_fn(|_| vec![f32::NAN; n]); - let mut y_short: [Vec; N_HORIZONS] = std::array::from_fn(|_| vec![f32::NAN; n]); + let mut y_prof_long: [Vec; N_HORIZONS] = std::array::from_fn(|_| vec![f32::NAN; n]); + let mut y_prof_short: [Vec; N_HORIZONS] = std::array::from_fn(|_| vec![f32::NAN; n]); + let mut y_size_long: [Vec; N_HORIZONS] = std::array::from_fn(|_| vec![f32::NAN; n]); + let mut y_size_short: [Vec; N_HORIZONS] = std::array::from_fn(|_| vec![f32::NAN; n]); + let mut sigma_k: [Vec; N_HORIZONS] = std::array::from_fn(|_| vec![f32::NAN; n]); + let mut pos_fraction = vec![0.0_f32; 2 * N_HORIZONS]; + + // σ_K floor — cost tick scale is the structural noise floor below which + // variance is degenerate (pearl_trade_level_vol_for_stop_distance). Use cost/4 + // as the conservative floor so size ratio remains bounded even on + // pathological constant-price segments. + let sigma_floor = cost_price_units / 4.0; for (h, &k) in horizons.iter().enumerate() { if n <= k { continue; } - let win_min = forward_window_min(prices, k); - let win_max = forward_window_max(prices, k); - // Valid range for full forward window: t in 0..=n-1-k, i.e. t+k <= n-1. + + // Pass 1: rolling Welford std of log-returns r[t] = ln(p[t+K] / p[t]). + // Maintain a window of the last SIGMA_WINDOW finite log-returns and + // recompute mean/var on the window. Window is small (1000) and N + // typically <= a few hundred K, so the O(W) per step is acceptable + // and avoids the numerical instability of subtracting popped values + // from a streaming sum-of-squares. + let mut window: VecDeque = VecDeque::with_capacity(SIGMA_WINDOW); for t in 0..n - k { let p_t = prices[t]; let p_kt = prices[t + k]; - // Guard against non-finite inputs anywhere in the path. The min/max - // helpers propagate NaN naturally (NaN compares false; deque keeps it), - // so we re-check on output here for clarity. - if !p_t.is_finite() - || !p_kt.is_finite() - || !win_min[t].is_finite() - || !win_max[t].is_finite() - { + // Compute σ_K[t] BEFORE pushing r[t] into the window, so σ_K[t] is + // strictly causal (no peek at the future bar's return). Window + // therefore must have warmed up via prior iterations. + if window.len() >= 2 { + // Sample variance via two-pass mean/var on the window (small W, + // accuracy > speed). Welford's online form would be marginal here. + let w_len = window.len() as f32; + let mean: f32 = window.iter().sum::() / w_len; + let var: f32 = window + .iter() + .map(|&x| { + let d = x - mean; + d * d + }) + .sum::() + / (w_len - 1.0); + let std = var.sqrt(); + sigma_k[h][t] = std.max(sigma_floor); + } else if sigma_floor > 0.0 { + // Even with no window data, expose the structural floor so + // downstream code can normalize meaningfully once cost > 0. + // With cost == 0 (test path) we leave NaN so y_size is masked. + sigma_k[h][t] = sigma_floor; + } + // Push r[t] into the window for FUTURE steps. + if p_t.is_finite() && p_kt.is_finite() && p_t > 0.0 && p_kt > 0.0 { + let r = (p_kt / p_t).ln(); + if r.is_finite() { + if window.len() == SIGMA_WINDOW { + window.pop_front(); + } + window.push_back(r); + } + } + } + + // Pass 2: populate prof + size labels using σ_K[t] and ΔP_K. + for t in 0..n - k { + let p_t = prices[t]; + let p_kt = prices[t + k]; + if !p_t.is_finite() || !p_kt.is_finite() { continue; } let delta = p_kt - p_t; - let dd_long = (p_t - win_min[t]).max(0.0); - let dd_short = (win_max[t] - p_t).max(0.0); - y_long[h][t] = (delta - cost_price_units) - 1.5 * dd_long; - y_short[h][t] = (-delta - cost_price_units) - 1.5 * dd_short; + let pnl_long = delta - cost_price_units; + let pnl_short = -delta - cost_price_units; + + // y_prof: profitable iff signed_pnl > 2×cost, i.e. pnl_{dir} > cost. + let prof_long = if pnl_long > cost_price_units { + 1.0 + } else { + 0.0 + }; + let prof_short = if pnl_short > cost_price_units { + 1.0 + } else { + 0.0 + }; + y_prof_long[h][t] = prof_long; + y_prof_short[h][t] = prof_short; + + // y_size: only defined where profitable AND σ_K finite + positive. + let sigma = sigma_k[h][t]; + if sigma.is_finite() && sigma > 0.0 { + if prof_long > 0.5 { + y_size_long[h][t] = pnl_long / sigma; + } + if prof_short > 0.5 { + y_size_short[h][t] = pnl_short / sigma; + } + } } + + // pos_fraction: finite-only count of positives over finite total, per dir. + let count = |arr: &[f32]| -> (usize, usize) { + let mut pos = 0_usize; + let mut tot = 0_usize; + for &v in arr { + if v.is_finite() { + tot += 1; + if v > 0.5 { + pos += 1; + } + } + } + (pos, tot) + }; + let (lp, lt) = count(&y_prof_long[h]); + let (sp, st) = count(&y_prof_short[h]); + pos_fraction[h] = if lt > 0 { lp as f32 / lt as f32 } else { 0.0 }; + pos_fraction[N_HORIZONS + h] = if st > 0 { sp as f32 / st as f32 } else { 0.0 }; } - Ok(OutcomeLabelsD { - y_long, - y_short, + Ok(OutcomeLabelsAB { + y_prof_long, + y_prof_short, + y_size_long, + y_size_short, + sigma_k, cost_price_units, + pos_fraction, }) } -/// Forward sliding-window minimum: `out[t] = min(prices[t..=t+k])` for -/// `t + k < n`, and `NaN` for the right-edge positions where the window -/// extends past the end of the input. -fn forward_window_min(prices: &[f32], k: usize) -> Vec { - let n = prices.len(); - let mut out = vec![f32::NAN; n]; - if n == 0 || k >= n { - return out; - } - let mut dq: VecDeque = VecDeque::new(); - // Seed the deque with the first window [0..=k]. - for j in 0..=k { - while let Some(&back) = dq.back() { - if prices[back] >= prices[j] { - dq.pop_back(); - } else { - break; - } - } - dq.push_back(j); - } - out[0] = prices[*dq.front().expect("deque non-empty after seeding")]; - // Slide forward: at each step drop the front if it leaves [t..=t+k], - // then push the new right edge (t+k). - for t in 1..n - k { - let new_j = t + k; - if let Some(&front) = dq.front() { - if front < t { - dq.pop_front(); - } - } - while let Some(&back) = dq.back() { - if prices[back] >= prices[new_j] { - dq.pop_back(); - } else { - break; - } - } - dq.push_back(new_j); - out[t] = prices[*dq.front().expect("deque non-empty after slide")]; - } - out -} - -/// Forward sliding-window maximum: `out[t] = max(prices[t..=t+k])` for -/// `t + k < n`, and `NaN` for the right-edge positions. -fn forward_window_max(prices: &[f32], k: usize) -> Vec { - let n = prices.len(); - let mut out = vec![f32::NAN; n]; - if n == 0 || k >= n { - return out; - } - let mut dq: VecDeque = VecDeque::new(); - for j in 0..=k { - while let Some(&back) = dq.back() { - if prices[back] <= prices[j] { - dq.pop_back(); - } else { - break; - } - } - dq.push_back(j); - } - out[0] = prices[*dq.front().expect("deque non-empty after seeding")]; - for t in 1..n - k { - let new_j = t + k; - if let Some(&front) = dq.front() { - if front < t { - dq.pop_front(); - } - } - while let Some(&back) = dq.back() { - if prices[back] <= prices[new_j] { - dq.pop_back(); - } else { - break; - } - } - dq.push_back(new_j); - out[t] = prices[*dq.front().expect("deque non-empty after slide")]; - } - out -} - #[cfg(test)] mod tests { use super::*; @@ -303,96 +340,235 @@ mod tests { assert!(out.labels.iter().all(|&y| y == 0.0 || y == 1.0)); } + // ===== A+B (Candidate-B) tests ===== + #[test] - fn d_labels_match_hand_derivation_simple_up() { - let prices = vec![100.0, 101.0]; // 1.0 up in 1 step - let labels = generate_outcome_labels_d(&prices, &[1; N_HORIZONS], 0.5).unwrap(); - // K=1, t=0: ΔP = +1.0; cost = 0.5 - // y_long = (1.0 - 0.5) - 1.5 × max(0, 100 - min(100, 101)) = 0.5 - 0 = 0.5 - // y_short = (-1.0 - 0.5) - 1.5 × max(0, max(100, 101) - 100) = -1.5 - 1.5 = -3.0 - assert!((labels.y_long[0][0] - 0.5).abs() < 1e-6); - assert!((labels.y_short[0][0] - (-3.0)).abs() < 1e-6); + fn ab_labels_match_hand_derivation_simple_up() { + // prices=[100, 102], K=1, cost=0.5 + // pnl_long = (102-100) - 0.5 = 1.5 > 0.5 → y_prof_long = 1 + // pnl_short = -2 - 0.5 = -2.5 ≤ 0.5 → y_prof_short = 0 + // With only one K-step return (and window pushes happen AFTER read), + // σ_K[0] has zero window samples → falls back to cost/4 = 0.125. + // y_size_long = 1.5 / 0.125 = 12.0 (size defined since prof==1). + // y_size_short = NaN (prof==0 masks it). + let labels = generate_outcome_labels_ab(&[100.0, 102.0], &[1; N_HORIZONS], 0.5).unwrap(); + assert_eq!(labels.y_prof_long[0][0], 1.0); + assert_eq!(labels.y_prof_short[0][0], 0.0); + // size: defined iff prof==1 AND sigma finite. Here sigma = floor = 0.125. + assert!(labels.y_size_long[0][0].is_finite()); + assert!(labels.y_size_short[0][0].is_nan()); + // Right edge: index 1 (t+K = 2 = n) has no forward window → NaN prof, NaN size. + assert!(labels.y_prof_long[0][1].is_nan()); + assert!(labels.y_size_long[0][1].is_nan()); } #[test] - fn d_labels_penalize_drawdown_before_recovery() { - let prices = vec![100.0, 99.0, 100.0, 101.0]; // drops then recovers; ΔP_K=3 = +1.0 - let labels = generate_outcome_labels_d(&prices, &[3; N_HORIZONS], 0.5).unwrap(); - // K=3, t=0: ΔP = +1.0; cost = 0.5 - // y_long: dd_against = 100 - min(100, 99, 100, 101) = 100 - 99 = 1.0 - // y_long = (1.0 - 0.5) - 1.5 × 1.0 = 0.5 - 1.5 = -1.0 - // y_short: dd_against = max(100, 99, 100, 101) - 100 = 1.0 - // y_short = (-1.0 - 0.5) - 1.5 × 1.0 = -1.5 - 1.5 = -3.0 - assert!((labels.y_long[0][0] - (-1.0)).abs() < 1e-6); - assert!((labels.y_short[0][0] - (-3.0)).abs() < 1e-6); - } - - #[test] - fn d_labels_nan_at_right_edge() { - let prices = vec![100.0; 5]; - let labels = generate_outcome_labels_d(&prices, &[3; N_HORIZONS], 0.5).unwrap(); - // For K=3, indices 0..=1 have full window; indices 2,3,4 lack t+K - assert!(labels.y_long[0][0].is_finite()); - assert!(labels.y_long[0][1].is_finite()); - assert!(labels.y_long[0][2].is_nan()); - assert!(labels.y_long[0][3].is_nan()); - assert!(labels.y_long[0][4].is_nan()); - } - - #[test] - fn d_labels_sliding_window_min_max_correct() { - let prices = vec![5.0, 3.0, 8.0, 1.0, 6.0]; - let labels = generate_outcome_labels_d(&prices, &[3; N_HORIZONS], 0.0).unwrap(); - // K=3, t=0: window [5,3,8,1]; ΔP = 1-5 = -4 - // y_long: dd_against = 5 - min(5,3,8,1) = 5 - 1 = 4 - // y_long = (-4 - 0) - 1.5 × 4 = -10 - // y_short: dd_against = max(5,3,8,1) - 5 = 8 - 5 = 3 - // y_short = (4 - 0) - 1.5 × 3 = -0.5 - assert!((labels.y_long[0][0] - (-10.0)).abs() < 1e-6); - assert!((labels.y_short[0][0] - (-0.5)).abs() < 1e-6); - } - - #[test] - fn d_labels_per_horizon_independent() { - // Monotonic +0.5 per step. - let prices: Vec = (0..20).map(|i| 100.0 + (i as f32 * 0.5)).collect(); - let horizons = [2, 5, 10]; - let labels = generate_outcome_labels_d(&prices, &horizons, 0.0).unwrap(); - // K=2: ΔP = 2 × 0.5 = 1.0; dd_against_long = 0 (monotonic up) - // y_long[0][0] = 1.0 - 0 = 1.0 - // K=5: ΔP = 2.5; y_long[1][0] = 2.5 - // K=10: ΔP = 5.0; y_long[2][0] = 5.0 - assert!((labels.y_long[0][0] - 1.0).abs() < 1e-6); - assert!((labels.y_long[1][0] - 2.5).abs() < 1e-6); - assert!((labels.y_long[2][0] - 5.0).abs() < 1e-6); - // Short on monotonic up should be strictly negative at every horizon. - assert!(labels.y_short[0][0] < 0.0); - assert!(labels.y_short[1][0] < 0.0); - assert!(labels.y_short[2][0] < 0.0); - } - - #[test] - fn d_labels_long_short_invariant_on_constant_series() { - // Constant prices: ΔP = 0, dd = 0 for both sides → both labels = -cost. - let prices = vec![100.0_f32; 10]; - let labels = generate_outcome_labels_d(&prices, &[3; N_HORIZONS], 0.5).unwrap(); - for t in 0..7 { - assert!((labels.y_long[0][t] - (-0.5)).abs() < 1e-6); - assert!((labels.y_short[0][t] - (-0.5)).abs() < 1e-6); + fn ab_labels_balanced_distribution_50_50() { + // Synthetic alternating ±1 walk of length 2002 at K=1, cost=0.0. + // At each t, ΔP alternates +1, -1; pnl_long > 0 on the +1 steps, + // pnl_short > 0 on the -1 steps. Strictly > cost = 0 means exactly + // half of t are y_prof_long=1, the other half y_prof_short=1. + let mut prices = Vec::with_capacity(2002); + let mut p = 100.0_f32; + prices.push(p); + for i in 0..2001 { + p += if i % 2 == 0 { 1.0 } else { -1.0 }; + prices.push(p); + } + let labels = generate_outcome_labels_ab(&prices, &[1; N_HORIZONS], 0.0).unwrap(); + // pos_fraction layout: [long_h0..h2, short_h0..h2] + let plong = labels.pos_fraction[0]; + let pshort = labels.pos_fraction[N_HORIZONS]; + // Expect ~50/50 within a tight tolerance (perfectly balanced construction). + assert!( + (plong - 0.5).abs() < 0.01, + "long pos_fraction={} not ~0.5", + plong + ); + assert!( + (pshort - 0.5).abs() < 0.01, + "short pos_fraction={} not ~0.5", + pshort + ); + // Invariant: each finite (t,dir) is profitable for exactly one direction. + let h = 0; + for t in 0..prices.len() - 1 { + let l = labels.y_prof_long[h][t]; + let s = labels.y_prof_short[h][t]; + if l.is_finite() && s.is_finite() { + assert!( + (l + s - 1.0).abs() < 1e-6, + "exactly one direction must be profitable at t={} (l={}, s={})", + t, + l, + s + ); + } } } #[test] - fn d_labels_reject_zero_horizon() { + fn ab_labels_warmup_window_produces_nan_sigma() { + // With cost=0 the floor is 0, so the warmup region (< 2 samples in window) + // leaves σ_K at NaN, which in turn forces y_size to NaN regardless of y_prof. + // Construct a long enough series that at least the first few t's have an + // empty window (window pushes happen AFTER read of σ_K[t]). + let prices: Vec = (0..50).map(|i| 100.0 + (i as f32 * 0.1)).collect(); + let labels = generate_outcome_labels_ab(&prices, &[1; N_HORIZONS], 0.0).unwrap(); + // t=0: window is empty when σ_K[0] is computed, cost=0 so no floor → NaN. + assert!(labels.sigma_k[0][0].is_nan()); + // y_size at t=0 must be NaN (sigma NaN), regardless of prof. + assert!(labels.y_size_long[0][0].is_nan()); + assert!(labels.y_size_short[0][0].is_nan()); + // By t=2 the window has >= 2 samples → sigma_k becomes finite. + assert!(labels.sigma_k[0][2].is_finite()); + } + + #[test] + fn ab_labels_sigma_floor_at_cost_scale() { + // Constant prices → all log-returns are zero → variance = 0 → std = 0. + // The floor must lift σ_K to cost/4. Use cost=0.4 so floor=0.1. + let prices = vec![100.0_f32; 200]; + let cost = 0.4_f32; + let labels = generate_outcome_labels_ab(&prices, &[1; N_HORIZONS], cost).unwrap(); + let floor = cost / 4.0; + // Mid-range t values have a non-empty window, so σ_K is computed and floored. + // (Edge cases like t=0 use the structural floor directly via the else branch.) + for &t in &[0_usize, 5, 50, 100, 150] { + let s = labels.sigma_k[0][t]; + assert!( + s.is_finite() && (s - floor).abs() < 1e-6, + "sigma_k[0][{}] = {} expected floor = {}", + t, + s, + floor + ); + } + // Constant prices: ΔP = 0; pnl_long = -cost, pnl_short = -cost; both ≤ cost + // → y_prof = 0 everywhere → y_size = NaN everywhere. + for t in 0..prices.len() - 1 { + assert_eq!(labels.y_prof_long[0][t], 0.0); + assert_eq!(labels.y_prof_short[0][t], 0.0); + assert!(labels.y_size_long[0][t].is_nan()); + assert!(labels.y_size_short[0][t].is_nan()); + } + } + + #[test] + fn ab_labels_cost_threshold_excludes_breakeven() { + // prices=[100, 100.5, 101], K=1, cost=0.5 + // t=0: ΔP = 0.5; pnl_long = 0.5 - 0.5 = 0.0; NOT > cost (0.5) → y_prof_long=0 + // pnl_short = -0.5 - 0.5 = -1.0; ≤ cost → y_prof_short=0 + // t=1: ΔP = 0.5; pnl_long = 0.0; same → y_prof_long=0 + let prices = vec![100.0_f32, 100.5, 101.0]; + let labels = generate_outcome_labels_ab(&prices, &[1; N_HORIZONS], 0.5).unwrap(); + assert_eq!(labels.y_prof_long[0][0], 0.0); + assert_eq!(labels.y_prof_short[0][0], 0.0); + assert_eq!(labels.y_prof_long[0][1], 0.0); + assert_eq!(labels.y_prof_short[0][1], 0.0); + // Size masked when prof == 0. + assert!(labels.y_size_long[0][0].is_nan()); + assert!(labels.y_size_long[0][1].is_nan()); + } + + #[test] + fn ab_labels_pos_fraction_per_horizon() { + // 10-step ramp up by 1.0/step at K=1, cost=0.0: + // pnl_long = +1.0 > 0 → y_prof_long = 1 at every valid t (9 of them). + // pnl_short = -1.0 ≤ 0 → y_prof_short = 0 at every valid t. + // pos_fraction[long, h=0] should be 1.0; [short, h=0] should be 0.0. + let prices: Vec = (0..10).map(|i| 100.0 + i as f32).collect(); + let labels = generate_outcome_labels_ab(&prices, &[1; N_HORIZONS], 0.0).unwrap(); + // Manual count. + let mut pos_l = 0_usize; + let mut tot_l = 0_usize; + let mut pos_s = 0_usize; + let mut tot_s = 0_usize; + for t in 0..prices.len() { + let l = labels.y_prof_long[0][t]; + let s = labels.y_prof_short[0][t]; + if l.is_finite() { + tot_l += 1; + if l > 0.5 { + pos_l += 1; + } + } + if s.is_finite() { + tot_s += 1; + if s > 0.5 { + pos_s += 1; + } + } + } + let expected_long = pos_l as f32 / tot_l as f32; + let expected_short = pos_s as f32 / tot_s as f32; + assert!( + (labels.pos_fraction[0] - expected_long).abs() < 1e-6, + "long pos_fraction mismatch: field={} manual={}", + labels.pos_fraction[0], + expected_long + ); + assert!( + (labels.pos_fraction[N_HORIZONS] - expected_short).abs() < 1e-6, + "short pos_fraction mismatch: field={} manual={}", + labels.pos_fraction[N_HORIZONS], + expected_short + ); + // Invariant on a strict-up ramp: long is universally profitable, short never. + assert!((labels.pos_fraction[0] - 1.0).abs() < 1e-6); + assert!(labels.pos_fraction[N_HORIZONS].abs() < 1e-6); + } + + #[test] + fn ab_labels_size_sign_matches_direction() { + // Strict-up ramp, K=1, cost=0.0: every long is profitable, sizes are + // strictly positive (pnl_long = +ΔP / σ_K > 0). + let prices: Vec = (0..2000).map(|i| 100.0 + i as f32 * 0.1).collect(); + let labels = generate_outcome_labels_ab(&prices, &[1; N_HORIZONS], 0.0).unwrap(); + let h = 0; + let mut checked = 0; + for t in 100..prices.len() - 1 { + if labels.y_size_long[h][t].is_finite() { + assert!( + labels.y_size_long[h][t] > 0.0, + "y_size_long at t={} = {} should be positive", + t, + labels.y_size_long[h][t] + ); + checked += 1; + } + // Short is unprofitable on a strict-up ramp → y_size_short stays NaN. + assert!(labels.y_size_short[h][t].is_nan()); + } + assert!(checked > 100, "expected many finite y_size_long entries"); + } + + #[test] + fn ab_labels_reject_zero_horizon() { let prices = vec![100.0_f32; 10]; - let err = generate_outcome_labels_d(&prices, &[0; N_HORIZONS], 0.5); + let err = generate_outcome_labels_ab(&prices, &[0; N_HORIZONS], 0.5); assert!(err.is_err()); } #[test] - fn d_labels_reject_negative_cost() { + fn ab_labels_reject_negative_cost() { let prices = vec![100.0_f32; 10]; - let err = generate_outcome_labels_d(&prices, &[3; N_HORIZONS], -0.1); + let err = generate_outcome_labels_ab(&prices, &[3; N_HORIZONS], -0.1); assert!(err.is_err()); } + + #[test] + fn ab_labels_right_edge_is_nan() { + let prices = vec![100.0_f32, 101.0, 102.0, 103.0, 104.0]; + let labels = generate_outcome_labels_ab(&prices, &[3; N_HORIZONS], 0.0).unwrap(); + // For K=3, valid t in [0, n-3) = [0, 2). t in {2,3,4} must be NaN prof. + assert!(labels.y_prof_long[0][0].is_finite()); + assert!(labels.y_prof_long[0][1].is_finite()); + for t in 2..5 { + assert!(labels.y_prof_long[0][t].is_nan()); + assert!(labels.y_prof_short[0][t].is_nan()); + assert!(labels.y_size_long[0][t].is_nan()); + assert!(labels.y_size_short[0][t].is_nan()); + } + } } diff --git a/crates/ml-alpha/src/trainer/perception.rs b/crates/ml-alpha/src/trainer/perception.rs index e4e9f93de..32c5c99cf 100644 --- a/crates/ml-alpha/src/trainer/perception.rs +++ b/crates/ml-alpha/src/trainer/perception.rs @@ -1011,6 +1011,14 @@ pub struct PerceptionTrainer { /// Threshold for directional accuracy in the lift detector (0.85 /// per E3 + spec). pub aux_lift_dir_acc_threshold: f32, + /// SDD-3 CB2: last observed per-(direction, horizon) positive-class + /// fraction from the loader, length `2 * N_AUX_HORIZONS` laid out as + /// `[long_h0..hN, short_h0..hN]`. Updated on every `step_batched` and + /// available for diagnostic readback. CB5 will consume this into the + /// BCE `pos_weight = (1 - p) / p` term inside the captured graph; CB2 + /// holds it on the host as a transition state so the data flow is + /// already plumbed end-to-end. + pub last_pos_fraction: Vec, /// `add_inplace` kernel handle — adds aux grad_x into the encoder /// grad buffer when stop-grad is lifted. Loaded from the /// `bucket_transition` cubin? Actually a dedicated `vec_add` cubin. @@ -2251,6 +2259,10 @@ impl PerceptionTrainer { // accuracy gate that proves aux is actually learning the // sign of the outcome (not just regressing to zero). aux_lift_dir_acc_threshold: 0.85, + // CB2: stash for the per-(direction, horizon) positive-class + // fraction; populated on every step_batched call. CB5 will + // consume this into the BCE pos_weight term. + last_pos_fraction: vec![0.0_f32; 2 * N_AUX_HORIZONS], aux_vec_add_fn, _aux_vec_add_module: aux_vec_add_module, }) @@ -2301,17 +2313,23 @@ impl PerceptionTrainer { /// [`step_batched`] with batch size 1. Preserves the existing /// API for single-sequence callers (test smokes, etc.). /// - /// SDD-3 Layer B5: aux outcome labels for both directions must be - /// supplied with the same shape as `labels_per_position`. NaN entries - /// are natively masked by the Huber loss kernel — callers that don't - /// have D-style outcome supervision for a particular position should - /// pass `[f32::NAN; N_AUX_HORIZONS]` rows. + /// SDD-3 CB2: callers pass the four A+B paired outcome arrays + /// (prof-binary + σ-normalized size for both directions). NaN entries + /// are natively masked by the existing Huber loss kernel — callers + /// without aux supervision for a particular position should pass + /// `[f32::NAN; N_AUX_HORIZONS]` rows. `pos_fraction` of length + /// `2 * N_AUX_HORIZONS` carries the per-(direction, horizon) positive + /// class fraction used by the CB5 BCE loss; pass zeros for the holding + /// pattern (the Huber path ignores them). pub fn step( &mut self, snapshots: &[Mbp10RawInput], labels_per_position: &[[f32; N_HORIZONS]], - outcome_long_per_position: &[[f32; N_AUX_HORIZONS]], - outcome_short_per_position: &[[f32; N_AUX_HORIZONS]], + outcome_prof_long_per_position: &[[f32; N_AUX_HORIZONS]], + outcome_prof_short_per_position: &[[f32; N_AUX_HORIZONS]], + outcome_size_long_per_position: &[[f32; N_AUX_HORIZONS]], + outcome_size_short_per_position: &[[f32; N_AUX_HORIZONS]], + pos_fraction: &[f32], ) -> Result { anyhow::ensure!( self.cfg.n_batch == 1, @@ -2322,8 +2340,11 @@ impl PerceptionTrainer { self.step_batched( &[snapshots], &[labels_per_position], - &[outcome_long_per_position], - &[outcome_short_per_position], + &[outcome_prof_long_per_position], + &[outcome_prof_short_per_position], + &[outcome_size_long_per_position], + &[outcome_size_short_per_position], + pos_fraction, ) } @@ -2381,12 +2402,23 @@ impl PerceptionTrainer { Ok(out) } + /// SDD-3 CB2 transition state: signature accepts the four A+B label + /// arrays (prof-binary + σ-normalized size, both directions) plus + /// per-file `pos_fraction`. The body still feeds the prof-binary + /// arrays through the D-era Huber path so the workspace compiles and + /// the trainer produces some training signal; CB5 owns the rewrite + /// to BCE-on-prof + Huber-on-size with `pos_weight = (1-p)/p` from + /// `pos_fraction`. The size and pos_fraction inputs are validated + + /// accepted but not yet consumed inside the captured graph. pub fn step_batched( &mut self, snapshots_batch: &[&[Mbp10RawInput]], labels_batch: &[&[[f32; N_HORIZONS]]], - outcome_long_batch: &[&[[f32; N_AUX_HORIZONS]]], - outcome_short_batch: &[&[[f32; N_AUX_HORIZONS]]], + outcome_prof_long_batch: &[&[[f32; N_AUX_HORIZONS]]], + outcome_prof_short_batch: &[&[[f32; N_AUX_HORIZONS]]], + outcome_size_long_batch: &[&[[f32; N_AUX_HORIZONS]]], + outcome_size_short_batch: &[&[[f32; N_AUX_HORIZONS]]], + pos_fraction: &[f32], ) -> Result { let b_sz = self.cfg.n_batch; let k_seq = self.cfg.seq_len; @@ -2396,9 +2428,22 @@ impl PerceptionTrainer { b_sz, snapshots_batch.len(), labels_batch.len() ); anyhow::ensure!( - outcome_long_batch.len() == b_sz && outcome_short_batch.len() == b_sz, - "expected B={} aux outcomes; got long={} short={}", - b_sz, outcome_long_batch.len(), outcome_short_batch.len() + outcome_prof_long_batch.len() == b_sz + && outcome_prof_short_batch.len() == b_sz + && outcome_size_long_batch.len() == b_sz + && outcome_size_short_batch.len() == b_sz, + "expected B={} aux outcomes; got prof_long={} prof_short={} size_long={} size_short={}", + b_sz, + outcome_prof_long_batch.len(), + outcome_prof_short_batch.len(), + outcome_size_long_batch.len(), + outcome_size_short_batch.len(), + ); + anyhow::ensure!( + pos_fraction.len() == 2 * N_AUX_HORIZONS, + "pos_fraction must be length 2 * N_AUX_HORIZONS ({}); got {}", + 2 * N_AUX_HORIZONS, + pos_fraction.len() ); for (b, snap) in snapshots_batch.iter().enumerate() { anyhow::ensure!( @@ -2414,20 +2459,45 @@ impl PerceptionTrainer { b, lbl.len(), k_seq ); } - for (b, ol) in outcome_long_batch.iter().enumerate() { + for (b, ol) in outcome_prof_long_batch.iter().enumerate() { anyhow::ensure!( ol.len() == k_seq, - "batch[{}] outcome_long.len()={} != seq_len={}", + "batch[{}] outcome_prof_long.len()={} != seq_len={}", b, ol.len(), k_seq ); } - for (b, os) in outcome_short_batch.iter().enumerate() { + for (b, os) in outcome_prof_short_batch.iter().enumerate() { anyhow::ensure!( os.len() == k_seq, - "batch[{}] outcome_short.len()={} != seq_len={}", + "batch[{}] outcome_prof_short.len()={} != seq_len={}", b, os.len(), k_seq ); } + for (b, ol) in outcome_size_long_batch.iter().enumerate() { + anyhow::ensure!( + ol.len() == k_seq, + "batch[{}] outcome_size_long.len()={} != seq_len={}", + b, ol.len(), k_seq + ); + } + for (b, os) in outcome_size_short_batch.iter().enumerate() { + anyhow::ensure!( + os.len() == k_seq, + "batch[{}] outcome_size_short.len()={} != seq_len={}", + b, os.len(), k_seq + ); + } + // CB2 transition state: stash pos_fraction on the trainer for + // diagnostic readback. CB5 will lift this into a mapped-pinned + // upload before the BCE loss kernel records `pos_weight = (1-p)/p` + // per (direction, horizon). + self.last_pos_fraction.copy_from_slice(pos_fraction); + // CB2 transition state: the size-regression targets + // (outcome_size_{long,short}_batch) are validated above (shape + // checks) but NOT yet routed into the captured-graph staging. + // CB5 owns the kernel rewrite that consumes both prof + size into + // BCE + Huber respectively and will add the matching `stg_aux_size_*` + // mapped buffers + per-K backward slot pointers. debug_assert_eq!(self.window_tensor_d.shape(), &[b_sz, k_seq, FEATURE_DIM]); let total_snaps = b_sz * k_seq; @@ -2488,10 +2558,14 @@ impl PerceptionTrainer { } } } - // SDD-3 Layer B5: aux outcome staging. Same layout convention as - // `stg_labels` ([K × B × N_AUX_HORIZONS] row-major, horizon innermost) - // so the per-K backward slot pointers can be derived by the same - // arithmetic as the main BCE labels. + // SDD-3 CB2 transition state: aux outcome staging now sources the + // prof-binary arrays (long/short) from the A+B label set. Layout is + // unchanged ([K × B × N_AUX_HORIZONS] row-major, horizon innermost) + // so the existing per-K backward slot pointers in the captured graph + // still resolve. The σ-normalized size targets are routed through + // CB5 — for now the holding-pattern D-era Huber kernel runs on the + // prof-binary arrays which provides a finite, NaN-masked signal + // (no convergence guarantees until CB5 lands the proper BCE+Huber). { let dst_long = self.stg_aux_y_long.host_slice_mut(); let dst_short = self.stg_aux_y_short.host_slice_mut(); @@ -2499,8 +2573,8 @@ impl PerceptionTrainer { for b_idx in 0..b_sz { for h in 0..N_AUX_HORIZONS { let idx = (k * b_sz + b_idx) * N_AUX_HORIZONS + h; - dst_long[idx] = outcome_long_batch[b_idx][k][h]; - dst_short[idx] = outcome_short_batch[b_idx][k][h]; + dst_long[idx] = outcome_prof_long_batch[b_idx][k][h]; + dst_short[idx] = outcome_prof_short_batch[b_idx][k][h]; } } } diff --git a/crates/ml-alpha/tests/perception_overfit.rs b/crates/ml-alpha/tests/perception_overfit.rs index a11da2bd4..1d9952356 100644 --- a/crates/ml-alpha/tests/perception_overfit.rs +++ b/crates/ml-alpha/tests/perception_overfit.rs @@ -55,28 +55,46 @@ fn synthetic_seq( (out, labels) } -/// SDD-3 Layer B5: synthetic aux-outcome generator matching the +0.25 -/// price ramp from `synthetic_seq`. For a constant up-trend with no -/// drawdown, D-style labels reduce to: -/// y_long = (K × 0.25) − cost − 0 (no long drawdown) -/// y_short = -K × 0.25 − cost − 1.5 × K × 0.25 (full short drawup) -/// Returns per-position rows of N_AUX_HORIZONS floats for both directions. -/// We approximate horizon-specific values via a per-horizon multiplier; -/// the exact value isn't critical for the constant-signal smoke — the -/// invariant is that y_long > y_short by a non-trivial margin at every -/// position, so the directional-accuracy gate measures correctly. +/// SDD-3 CB2: synthetic aux-outcome generator matching the +0.25 price +/// ramp from `synthetic_seq`. For a constant up-trend the A+B paired +/// targets are: +/// y_prof_long = 1.0 (every long position is profitable after 2×cost) +/// y_prof_short = 0.0 (every short position loses) +/// y_size_long ≈ +ratio (positive σ-normalized signed return) +/// y_size_short = NaN (unprofitable side: size target masked) +/// Returns five vectors of `[N_AUX_HORIZONS]` rows + the per-(dir,horizon) +/// positive-class fraction expected by the trainer signature. fn synthetic_aux_outcomes( seq_len: usize, -) -> (Vec<[f32; N_AUX_HORIZONS]>, Vec<[f32; N_AUX_HORIZONS]>) { - // Per-horizon target magnitudes (in ticks). Long is positive, short - // is negative — the trainer should learn both directions cleanly. - let long_per_h: [f32; N_AUX_HORIZONS] = [0.5, 1.5, 5.0]; - let short_per_h: [f32; N_AUX_HORIZONS] = [-0.5, -1.5, -5.0]; - let long_row = std::array::from_fn::(|h| long_per_h[h]); - let short_row = std::array::from_fn::(|h| short_per_h[h]); - let outcome_long = vec![long_row; seq_len]; - let outcome_short = vec![short_row; seq_len]; - (outcome_long, outcome_short) +) -> AuxOutcomes { + let prof_long_row = std::array::from_fn::(|_| 1.0); + let prof_short_row = std::array::from_fn::(|_| 0.0); + // Per-horizon magnitude — exact values don't matter for the smoke; + // the invariant is sign-correctness so the dir_acc gate measures. + let size_long_per_h: [f32; N_AUX_HORIZONS] = [0.5, 1.5, 5.0]; + let size_long_row = std::array::from_fn::(|h| size_long_per_h[h]); + // Short is unprofitable → size masked with NaN (loader convention). + let size_short_row = std::array::from_fn::(|_| f32::NAN); + // pos_fraction is 1.0 for long (all positive) and 0.0 for short. + let mut pos_fraction = vec![0.0_f32; 2 * N_AUX_HORIZONS]; + for h in 0..N_AUX_HORIZONS { pos_fraction[h] = 1.0; } + AuxOutcomes { + prof_long: vec![prof_long_row; seq_len], + prof_short: vec![prof_short_row; seq_len], + size_long: vec![size_long_row; seq_len], + size_short: vec![size_short_row; seq_len], + pos_fraction, + } +} + +/// CB2: bundle for the A+B paired aux-outcome targets returned by +/// `synthetic_aux_outcomes` and consumed by `PerceptionTrainer::step`. +struct AuxOutcomes { + prof_long: Vec<[f32; N_AUX_HORIZONS]>, + prof_short: Vec<[f32; N_AUX_HORIZONS]>, + size_long: Vec<[f32; N_AUX_HORIZONS]>, + size_short: Vec<[f32; N_AUX_HORIZONS]>, + pos_fraction: Vec, } #[test] @@ -104,7 +122,7 @@ fn stacked_trainer_loss_shrinks_on_constant_signal() { lr_aux: 3e-3, }; let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init"); - let (out_long, out_short) = synthetic_aux_outcomes(cfg.seq_len); + let aux = synthetic_aux_outcomes(cfg.seq_len); // Initial loss over 8 batches. let mut initial_total = 0.0_f32; @@ -112,7 +130,15 @@ fn stacked_trainer_loss_shrinks_on_constant_signal() { let mut prev_mid = 5500.0_f32; for _ in 0..8 { let (seq, labels) = synthetic_seq(cfg.seq_len, prev_mid, ts); - let l = trainer.step(&seq, labels.as_slice(), out_long.as_slice(), out_short.as_slice()).expect("step warm"); + let l = trainer.step( + &seq, + labels.as_slice(), + aux.prof_long.as_slice(), + aux.prof_short.as_slice(), + aux.size_long.as_slice(), + aux.size_short.as_slice(), + &aux.pos_fraction, + ).expect("step warm"); initial_total += l; prev_mid = 0.5 * (seq.last().unwrap().bid_px[0] + seq.last().unwrap().ask_px[0]); ts = seq.last().unwrap().ts_ns; @@ -125,7 +151,15 @@ fn stacked_trainer_loss_shrinks_on_constant_signal() { let mut window_count = 0usize; for step_idx in 0..250 { let (seq, labels) = synthetic_seq(cfg.seq_len, prev_mid, ts); - let l = trainer.step(&seq, labels.as_slice(), out_long.as_slice(), out_short.as_slice()).expect("train step"); + let l = trainer.step( + &seq, + labels.as_slice(), + aux.prof_long.as_slice(), + aux.prof_short.as_slice(), + aux.size_long.as_slice(), + aux.size_short.as_slice(), + &aux.pos_fraction, + ).expect("train step"); window_loss += l; window_count += 1; if step_idx % 50 == 49 { @@ -145,7 +179,15 @@ fn stacked_trainer_loss_shrinks_on_constant_signal() { let mut final_total = 0.0_f32; for _ in 0..8 { let (seq, labels) = synthetic_seq(cfg.seq_len, prev_mid, ts); - let l = trainer.step(&seq, labels.as_slice(), out_long.as_slice(), out_short.as_slice()).expect("step final"); + let l = trainer.step( + &seq, + labels.as_slice(), + aux.prof_long.as_slice(), + aux.prof_short.as_slice(), + aux.size_long.as_slice(), + aux.size_short.as_slice(), + &aux.pos_fraction, + ).expect("step final"); final_total += l; prev_mid = 0.5 * (seq.last().unwrap().bid_px[0] + seq.last().unwrap().ask_px[0]); ts = seq.last().unwrap().ts_ns; @@ -182,14 +224,22 @@ fn stacked_trainer_loss_shrinks_with_stride_4() { lr_aux: 3e-3, }; let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init"); - let (out_long, out_short) = synthetic_aux_outcomes(cfg.seq_len); + let aux = synthetic_aux_outcomes(cfg.seq_len); let mut initial = 0.0_f32; let mut ts = 1_000_000u64; let mut prev_mid = 5500.0_f32; for _ in 0..8 { let (seq, labels) = synthetic_seq(cfg.seq_len, prev_mid, ts); - let l = trainer.step(&seq, labels.as_slice(), out_long.as_slice(), out_short.as_slice()).expect("step"); + let l = trainer.step( + &seq, + labels.as_slice(), + aux.prof_long.as_slice(), + aux.prof_short.as_slice(), + aux.size_long.as_slice(), + aux.size_short.as_slice(), + &aux.pos_fraction, + ).expect("step"); initial += l; prev_mid = 0.5 * (seq.last().unwrap().bid_px[0] + seq.last().unwrap().ask_px[0]); ts = seq.last().unwrap().ts_ns; @@ -199,7 +249,15 @@ fn stacked_trainer_loss_shrinks_with_stride_4() { for _ in 0..200 { let (seq, labels) = synthetic_seq(cfg.seq_len, prev_mid, ts); - trainer.step(&seq, labels.as_slice(), out_long.as_slice(), out_short.as_slice()).expect("step"); + trainer.step( + &seq, + labels.as_slice(), + aux.prof_long.as_slice(), + aux.prof_short.as_slice(), + aux.size_long.as_slice(), + aux.size_short.as_slice(), + &aux.pos_fraction, + ).expect("step"); prev_mid = 0.5 * (seq.last().unwrap().bid_px[0] + seq.last().unwrap().ask_px[0]); ts = seq.last().unwrap().ts_ns; } @@ -207,7 +265,15 @@ fn stacked_trainer_loss_shrinks_with_stride_4() { let mut final_loss = 0.0_f32; for _ in 0..8 { let (seq, labels) = synthetic_seq(cfg.seq_len, prev_mid, ts); - final_loss += trainer.step(&seq, labels.as_slice(), out_long.as_slice(), out_short.as_slice()).expect("step"); + final_loss += trainer.step( + &seq, + labels.as_slice(), + aux.prof_long.as_slice(), + aux.prof_short.as_slice(), + aux.size_long.as_slice(), + aux.size_short.as_slice(), + &aux.pos_fraction, + ).expect("step"); prev_mid = 0.5 * (seq.last().unwrap().bid_px[0] + seq.last().unwrap().ask_px[0]); ts = seq.last().unwrap().ts_ns; } @@ -258,18 +324,26 @@ fn stacked_trainer_loss_shrinks_at_batch_32() { } (seqs, labels) }; - // SDD-3 Layer B5: synthetic aux outcomes are seq_len-determined and - // direction-asymmetric; one shared pair of [N_HORIZONS]-rows is broadcast - // across all B samples per step. - let (out_long_seq, out_short_seq) = synthetic_aux_outcomes(cfg.seq_len); - let out_long_batch: Vec> = - (0..cfg.n_batch).map(|_| out_long_seq.clone()).collect(); - let out_short_batch: Vec> = - (0..cfg.n_batch).map(|_| out_short_seq.clone()).collect(); - let out_long_refs: Vec<&[[f32; N_AUX_HORIZONS]]> = - out_long_batch.iter().map(|v| v.as_slice()).collect(); - let out_short_refs: Vec<&[[f32; N_AUX_HORIZONS]]> = - out_short_batch.iter().map(|v| v.as_slice()).collect(); + // SDD-3 CB2: synthetic aux outcomes are seq_len-determined and + // direction-asymmetric; the shared per-sample rows for both prof-binary + // and size targets are broadcast across all B samples per step. + let aux_seq = synthetic_aux_outcomes(cfg.seq_len); + let prof_long_batch: Vec> = + (0..cfg.n_batch).map(|_| aux_seq.prof_long.clone()).collect(); + let prof_short_batch: Vec> = + (0..cfg.n_batch).map(|_| aux_seq.prof_short.clone()).collect(); + let size_long_batch: Vec> = + (0..cfg.n_batch).map(|_| aux_seq.size_long.clone()).collect(); + let size_short_batch: Vec> = + (0..cfg.n_batch).map(|_| aux_seq.size_short.clone()).collect(); + let prof_long_refs: Vec<&[[f32; N_AUX_HORIZONS]]> = + prof_long_batch.iter().map(|v| v.as_slice()).collect(); + let prof_short_refs: Vec<&[[f32; N_AUX_HORIZONS]]> = + prof_short_batch.iter().map(|v| v.as_slice()).collect(); + let size_long_refs: Vec<&[[f32; N_AUX_HORIZONS]]> = + size_long_batch.iter().map(|v| v.as_slice()).collect(); + let size_short_refs: Vec<&[[f32; N_AUX_HORIZONS]]> = + size_short_batch.iter().map(|v| v.as_slice()).collect(); let mut initial = 0.0_f32; for warmup in 0..4 { @@ -277,7 +351,15 @@ fn stacked_trainer_loss_shrinks_at_batch_32() { let seq_refs: Vec<&[Mbp10RawInput]> = seqs.iter().map(|s| s.as_slice()).collect(); let lbl_refs: Vec<&[[f32; N_HORIZONS]]> = labels.iter().map(|l| l.as_slice()).collect(); let l = trainer - .step_batched(&seq_refs, &lbl_refs, &out_long_refs, &out_short_refs) + .step_batched( + &seq_refs, + &lbl_refs, + &prof_long_refs, + &prof_short_refs, + &size_long_refs, + &size_short_refs, + &aux_seq.pos_fraction, + ) .expect("warm step"); if warmup >= 2 { initial += l; @@ -291,7 +373,15 @@ fn stacked_trainer_loss_shrinks_at_batch_32() { let seq_refs: Vec<&[Mbp10RawInput]> = seqs.iter().map(|s| s.as_slice()).collect(); let lbl_refs: Vec<&[[f32; N_HORIZONS]]> = labels.iter().map(|l| l.as_slice()).collect(); trainer - .step_batched(&seq_refs, &lbl_refs, &out_long_refs, &out_short_refs) + .step_batched( + &seq_refs, + &lbl_refs, + &prof_long_refs, + &prof_short_refs, + &size_long_refs, + &size_short_refs, + &aux_seq.pos_fraction, + ) .expect("train step"); } @@ -301,7 +391,15 @@ fn stacked_trainer_loss_shrinks_at_batch_32() { let seq_refs: Vec<&[Mbp10RawInput]> = seqs.iter().map(|s| s.as_slice()).collect(); let lbl_refs: Vec<&[[f32; N_HORIZONS]]> = labels.iter().map(|l| l.as_slice()).collect(); final_loss += trainer - .step_batched(&seq_refs, &lbl_refs, &out_long_refs, &out_short_refs) + .step_batched( + &seq_refs, + &lbl_refs, + &prof_long_refs, + &prof_short_refs, + &size_long_refs, + &size_short_refs, + &aux_seq.pos_fraction, + ) .expect("final step"); } final_loss /= 4.0; @@ -390,14 +488,22 @@ fn evaluate_works_after_captured_training_step() { lr_aux: 3e-3, }; let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init"); - let (out_long, out_short) = synthetic_aux_outcomes(cfg.seq_len); + let aux = synthetic_aux_outcomes(cfg.seq_len); // Drive enough training steps to exercise warmup → capture → replay. let mut ts = 1_000_000u64; let mut prev_mid = 5500.0_f32; for _ in 0..5 { let (seq, labels) = synthetic_seq(cfg.seq_len, prev_mid, ts); - trainer.step(&seq, labels.as_slice(), out_long.as_slice(), out_short.as_slice()).expect("train step"); + trainer.step( + &seq, + labels.as_slice(), + aux.prof_long.as_slice(), + aux.prof_short.as_slice(), + aux.size_long.as_slice(), + aux.size_short.as_slice(), + &aux.pos_fraction, + ).expect("train step"); prev_mid = 0.5 * (seq.last().unwrap().bid_px[0] + seq.last().unwrap().ask_px[0]); ts = seq.last().unwrap().ts_ns; } @@ -429,12 +535,28 @@ fn evaluate_works_after_capture_no_replay() { lr_aux: 3e-3, }; let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init"); - let (out_long, out_short) = synthetic_aux_outcomes(cfg.seq_len); + let aux = synthetic_aux_outcomes(cfg.seq_len); let ts = 1_000_000u64; let prev_mid = 5500.0_f32; let (seq, labels) = synthetic_seq(cfg.seq_len, prev_mid, ts); - trainer.step(&seq, labels.as_slice(), out_long.as_slice(), out_short.as_slice()).expect("warmup step"); - trainer.step(&seq, labels.as_slice(), out_long.as_slice(), out_short.as_slice()).expect("capture step"); + trainer.step( + &seq, + labels.as_slice(), + aux.prof_long.as_slice(), + aux.prof_short.as_slice(), + aux.size_long.as_slice(), + aux.size_short.as_slice(), + &aux.pos_fraction, + ).expect("warmup step"); + trainer.step( + &seq, + labels.as_slice(), + aux.prof_long.as_slice(), + aux.prof_short.as_slice(), + aux.size_long.as_slice(), + aux.size_short.as_slice(), + &aux.pos_fraction, + ).expect("capture step"); // Eval right after capture, no replay. let (loss, _probs) = trainer.evaluate(&seq, labels.as_slice()).expect("eval after capture"); assert!(loss.is_finite(), "eval loss must be finite, got {loss}"); @@ -463,7 +585,7 @@ fn horizon_ema_and_lambda_track_after_training() { lr_aux: 3e-3, }; let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init"); - let (out_long, out_short) = synthetic_aux_outcomes(cfg.seq_len); + let aux = synthetic_aux_outcomes(cfg.seq_len); // EMA / lambda before any training step. let ema0 = trainer.loss_ema_snapshot().expect("ema initial"); @@ -475,7 +597,15 @@ fn horizon_ema_and_lambda_track_after_training() { let mut prev_mid = 5500.0_f32; for _ in 0..5 { let (seq, labels) = synthetic_seq(cfg.seq_len, prev_mid, ts); - trainer.step(&seq, labels.as_slice(), out_long.as_slice(), out_short.as_slice()).expect("train step"); + trainer.step( + &seq, + labels.as_slice(), + aux.prof_long.as_slice(), + aux.prof_short.as_slice(), + aux.size_long.as_slice(), + aux.size_short.as_slice(), + &aux.pos_fraction, + ).expect("train step"); prev_mid = 0.5 * (seq.last().unwrap().bid_px[0] + seq.last().unwrap().ask_px[0]); ts = seq.last().unwrap().ts_ns; } @@ -529,7 +659,7 @@ fn stacked_trainer_aux_supervision_converges_on_constant_signal() { lr_aux: 3e-3, }; let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init"); - let (out_long, out_short) = synthetic_aux_outcomes(cfg.seq_len); + let aux = synthetic_aux_outcomes(cfg.seq_len); let mut ts = 1_000_000u64; let mut prev_mid = 5500.0_f32; @@ -540,8 +670,11 @@ fn stacked_trainer_aux_supervision_converges_on_constant_signal() { .step( &seq, labels.as_slice(), - out_long.as_slice(), - out_short.as_slice(), + aux.prof_long.as_slice(), + aux.prof_short.as_slice(), + aux.size_long.as_slice(), + aux.size_short.as_slice(), + &aux.pos_fraction, ) .expect("train step"); prev_mid = 0.5 * (seq.last().unwrap().bid_px[0] + seq.last().unwrap().ask_px[0]); @@ -605,12 +738,20 @@ fn evaluate_works_after_warmup_only() { lr_aux: 3e-3, }; let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init"); - let (out_long, out_short) = synthetic_aux_outcomes(cfg.seq_len); + let aux = synthetic_aux_outcomes(cfg.seq_len); let ts = 1_000_000u64; let prev_mid = 5500.0_f32; let (seq, labels) = synthetic_seq(cfg.seq_len, prev_mid, ts); // ONLY one step — warmup, no capture yet. - trainer.step(&seq, labels.as_slice(), out_long.as_slice(), out_short.as_slice()).expect("warmup step"); + trainer.step( + &seq, + labels.as_slice(), + aux.prof_long.as_slice(), + aux.prof_short.as_slice(), + aux.size_long.as_slice(), + aux.size_short.as_slice(), + &aux.pos_fraction, + ).expect("warmup step"); // Now eval. let (loss, _probs) = trainer.evaluate(&seq, labels.as_slice()).expect("eval after warmup"); assert!(loss.is_finite(), "eval loss must be finite, got {loss}");