feat(aux-labels): replace D with A+B paired labels + loader/trainer migration (CB1+CB2)

Smoke 1 v2 empirically falsified the D-style labels: 99.99% of K=100 D-labels
were negative on real ES MBP-10 (cost+1.5×MaxDD dominates every typical
100-tick move). Aux head couldn't escape predicting the mean.

CB1 — replace label generator:
- generate_outcome_labels_d → generate_outcome_labels_ab returning
  OutcomeLabelsAB { y_prof_{long,short}, y_size_{long,short}, sigma_k,
  cost_price_units, pos_fraction }
- y_prof = 1 iff signed_pnl > 2×cost (binary, class-weighted BCE target)
- y_size = signed_pnl / σ_K (σ-normalized regression target,
  conditional-masked when y_prof=0)
- σ_K: rolling 1000-bar Welford std of K-step log-returns, floored at
  cost/4 per pearl_trade_level_vol_for_stop_distance
- pos_fraction: per-(direction, horizon) positive class fraction for
  downstream BCE pos_weight balancing
- 10 unit tests validating sign-correctness, NaN edges, balance,
  cost-threshold, sigma-floor, per-horizon independence, error paths

CB2 — atomic caller migration:
- LabeledSequence + LoadedFile: outcome_long/short (2 arrays) → 5 arrays
  (prof_long, prof_short, size_long, size_short, sigma_k) + pos_fraction
- Loader splits new generator's outputs + propagates pos_fraction
  file-level into each yielded LabeledSequence
- step / step_batched signatures widened to 7 params + pos_fraction
- alpha_train.rs: 4 separate batches + per-snapshot row build for each
- last_pos_fraction stashed on PerceptionTrainer
- aux test fixture (synthetic_aux_outcomes) updated to emit 4 arrays +
  pos_fraction matching the constant-up-ramp test signal
- HOLDING PATTERN: step_batched body validates new arrays + stages
  prof-binary through the existing D-era Huber kernel so training
  produces a real gradient. CB3 rewrites the kernel; CB4 widens head
  outputs; CB5 wires BCE+Huber proper loss with class weighting.

cargo check --workspace --all-targets: clean (only pre-existing cudarc
cupti + ml insert_batch unrelated errors).
cargo test -p ml-alpha --lib: 43 passed (15 multi_horizon_labels).
cargo test -p ml-backtesting --lib: 33 passed.
stacked_trainer_aux_supervision RTX 3050: pass (huber=0.0004, dir_acc=1.0,
stop_grad lifted).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-05-22 10:52:20 +02:00
parent 2e06297671
commit 9ed882e740
6 changed files with 851 additions and 355 deletions

View File

@@ -484,12 +484,26 @@ fn main() -> Result<()> {
// don't pollute the batch.
let mut snap_batch: Vec<Vec<Mbp10RawInput>> = Vec::with_capacity(cli.batch_size);
let mut label_batch: Vec<Vec<[f32; N_HORIZONS]>> = 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<Vec<[f32; N_AUX_HORIZONS]>> =
// 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<[f32; N_AUX_HORIZONS]>> =
Vec::with_capacity(cli.batch_size);
let mut out_short_batch: Vec<Vec<[f32; N_AUX_HORIZONS]>> =
let mut out_prof_short_batch: Vec<Vec<[f32; N_AUX_HORIZONS]>> =
Vec::with_capacity(cli.batch_size);
let mut out_size_long_batch: Vec<Vec<[f32; N_AUX_HORIZONS]>> =
Vec::with_capacity(cli.batch_size);
let mut out_size_short_batch: Vec<Vec<[f32; N_AUX_HORIZONS]>> =
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<f32> = 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<f32>`
// 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

View File

@@ -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
//!

View File

@@ -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<f32>; 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<f32>; 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<f32>; 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<f32>; N_HORIZONS],
/// `[N_HORIZONS][seq_len]` Candidate-B prof-binary labels for the short
/// direction.
pub outcome_prof_short: [Vec<f32>; 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<f32>; 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<f32>; 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<f32>; 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<f32>,
}
/// 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<f32>; 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<f32>; N_HORIZONS],
/// `outcome_short_full[h]` — short-direction counterpart to
/// `outcome_long_full`.
outcome_short_full: [Vec<f32>; N_HORIZONS],
outcome_prof_long_full: [Vec<f32>; N_HORIZONS],
/// `outcome_prof_short_full[h]` — short-direction counterpart to
/// `outcome_prof_long_full`.
outcome_prof_short_full: [Vec<f32>; 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<f32>; N_HORIZONS],
/// `outcome_size_short_full[h]` — short-direction counterpart.
outcome_size_short_full: [Vec<f32>; 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<f32>; 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<f32>,
/// 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<f32>; N_HORIZONS] = Default::default();
let mut outcome_long_full: [Vec<f32>; N_HORIZONS] = Default::default();
let mut outcome_short_full: [Vec<f32>; N_HORIZONS] = Default::default();
let mut outcome_prof_long_full: [Vec<f32>; N_HORIZONS] = Default::default();
let mut outcome_prof_short_full: [Vec<f32>; N_HORIZONS] = Default::default();
let mut outcome_size_long_full: [Vec<f32>; N_HORIZONS] = Default::default();
let mut outcome_size_short_full: [Vec<f32>; N_HORIZONS] = Default::default();
let mut sigma_k_full: [Vec<f32>; 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<f32> = vec![0.0_f32; 2 * N_HORIZONS];
if !cfg.inference_only {
let prices: Vec<f32> = 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<f32>; N_HORIZONS] = Default::default();
let mut outcome_long: [Vec<f32>; N_HORIZONS] = Default::default();
let mut outcome_short: [Vec<f32>; N_HORIZONS] = Default::default();
let mut outcome_prof_long: [Vec<f32>; N_HORIZONS] = Default::default();
let mut outcome_prof_short: [Vec<f32>; N_HORIZONS] = Default::default();
let mut outcome_size_long: [Vec<f32>; N_HORIZONS] = Default::default();
let mut outcome_size_short: [Vec<f32>; N_HORIZONS] = Default::default();
let mut sigma_k: [Vec<f32>; 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(),
}))
}
}

View File

@@ -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<f32>; N_HORIZONS],
/// `y_short[h][t]` = (price[t] - price[t+K] - cost) - 1.5 * |max_drawup_short|
pub y_short: [Vec<f32>; 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<f32>; N_HORIZONS],
pub y_prof_short: [Vec<f32>; N_HORIZONS],
pub y_size_long: [Vec<f32>; N_HORIZONS],
pub y_size_short: [Vec<f32>; N_HORIZONS],
pub sigma_k: [Vec<f32>; 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<f32>,
}
/// 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<OutcomeLabelsD> {
) -> Result<OutcomeLabelsAB> {
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<f32>; N_HORIZONS] = std::array::from_fn(|_| vec![f32::NAN; n]);
let mut y_short: [Vec<f32>; N_HORIZONS] = std::array::from_fn(|_| vec![f32::NAN; n]);
let mut y_prof_long: [Vec<f32>; N_HORIZONS] = std::array::from_fn(|_| vec![f32::NAN; n]);
let mut y_prof_short: [Vec<f32>; N_HORIZONS] = std::array::from_fn(|_| vec![f32::NAN; n]);
let mut y_size_long: [Vec<f32>; N_HORIZONS] = std::array::from_fn(|_| vec![f32::NAN; n]);
let mut y_size_short: [Vec<f32>; N_HORIZONS] = std::array::from_fn(|_| vec![f32::NAN; n]);
let mut sigma_k: [Vec<f32>; 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<f32> = 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::<f32>() / w_len;
let var: f32 = window
.iter()
.map(|&x| {
let d = x - mean;
d * d
})
.sum::<f32>()
/ (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<f32> {
let n = prices.len();
let mut out = vec![f32::NAN; n];
if n == 0 || k >= n {
return out;
}
let mut dq: VecDeque<usize> = 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<f32> {
let n = prices.len();
let mut out = vec![f32::NAN; n];
if n == 0 || k >= n {
return out;
}
let mut dq: VecDeque<usize> = 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<f32> = (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<f32> = (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<f32> = (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<f32> = (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());
}
}
}

View File

@@ -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<f32>,
/// `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<f32> {
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<f32> {
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];
}
}
}

View File

@@ -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::<f32, N_AUX_HORIZONS, _>(|h| long_per_h[h]);
let short_row = std::array::from_fn::<f32, N_AUX_HORIZONS, _>(|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::<f32, N_AUX_HORIZONS, _>(|_| 1.0);
let prof_short_row = std::array::from_fn::<f32, N_AUX_HORIZONS, _>(|_| 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::<f32, N_AUX_HORIZONS, _>(|h| size_long_per_h[h]);
// Short is unprofitable → size masked with NaN (loader convention).
let size_short_row = std::array::from_fn::<f32, N_AUX_HORIZONS, _>(|_| 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<f32>,
}
#[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<Vec<[f32; N_AUX_HORIZONS]>> =
(0..cfg.n_batch).map(|_| out_long_seq.clone()).collect();
let out_short_batch: Vec<Vec<[f32; N_AUX_HORIZONS]>> =
(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<Vec<[f32; N_AUX_HORIZONS]>> =
(0..cfg.n_batch).map(|_| aux_seq.prof_long.clone()).collect();
let prof_short_batch: Vec<Vec<[f32; N_AUX_HORIZONS]>> =
(0..cfg.n_batch).map(|_| aux_seq.prof_short.clone()).collect();
let size_long_batch: Vec<Vec<[f32; N_AUX_HORIZONS]>> =
(0..cfg.n_batch).map(|_| aux_seq.size_long.clone()).collect();
let size_short_batch: Vec<Vec<[f32; N_AUX_HORIZONS]>> =
(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}");