feat(aux-supervision): wire AuxTrunk + AuxHeads + Huber loss into PerceptionTrainer (B5)

Wires the aux supervision path parallel to BCE:
- AuxTrunk (64-hidden single-bucket CfC) consumes the same encoder output
  as the main BCE trunk
- AuxHeads (linear regression long/short) maps aux_trunk output to
  per-(direction, horizon) predicted outcomes
- AuxHuberLoss supervises against D-style labels from MultiHorizonLoader

Backward path with asymmetric stop-grad at encoder boundary:
- aux_trunk gets gradient signal into its OWN params at all times
- aux_trunk's encoder-boundary gradient is INITIALLY blocked
  (stop_grad_aux_to_encoder = true)
- Conditional lift per E3 design: if aux_huber_ema < 0.4 AND
  aux_dir_acc_ema > 0.85 within 200 steps, lift the stop-grad
- When lifted, aux_vec_add kernel folds aux's grad_x into the main
  grad_h_enriched_seq slot (element-wise += per feedback_no_atomicadd)

ISV signals added: aux_huber_per_h, aux_dir_acc_per_h (per pearl).

Per-trunk scratch + reduced grad buffers (no Adam state sharing per
pearl_adam_normalizes_loss_weights — opt_aux is its own Adam group).

New helper kernel cuda/aux_vec_add.cu: position-local dst += src for
the asymmetric stop-grad lift accumulation.

New synthetic test stacked_trainer_aux_supervision_converges_on_constant_signal
validates end-to-end:
  aux_huber_ema_per_h    = [0.087, 0.087, 0.087]  (converged)
  aux_dir_acc_ema_per_h  = [1.0, 1.0, 1.0]        (perfect on constant)
  stop_grad_aux_to_encoder = false                (lift fired)

All 5 stacked_trainer tests pass on RTX 3050 (lib still converges, no
regression from parallel aux wiring).

Not yet consumed by decision policy (B7) — aux output flows through
training only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-05-22 09:45:29 +02:00
parent ff70734802
commit 21e7dfd63c
7 changed files with 1426 additions and 35 deletions

View File

@@ -30,9 +30,10 @@ const KERNELS: &[&str] = &[
"aux_trunk", // SDD-3 Layer B3: smaller single-bucket CfC trunk (AUX_HIDDEN=64) for outcome-supervision (D-labels) "aux_trunk", // SDD-3 Layer B3: smaller single-bucket CfC trunk (AUX_HIDDEN=64) for outcome-supervision (D-labels)
"aux_heads", // SDD-3 Layer B4: per-direction linear regression heads on AuxTrunk output (long + short, N_AUX_HORIZONS each) "aux_heads", // SDD-3 Layer B4: per-direction linear regression heads on AuxTrunk output (long + short, N_AUX_HORIZONS each)
"aux_loss", // SDD-3 Layer B4: Huber loss + grad for aux trade-outcome regression targets (NaN-masked) "aux_loss", // SDD-3 Layer B4: Huber loss + grad for aux trade-outcome regression targets (NaN-masked)
"aux_vec_add", // SDD-3 Layer B5: element-wise dst += src for aux→encoder gradient accumulation (lifted stop-grad)
]; ];
// Cache bust v17 (2026-05-22): SDD-3 Layer B4 — aux_heads + aux_loss cubins for trade-outcome regression. // Cache bust v18 (2026-05-22): SDD-3 Layer B5 — aux_heads + aux_trunk bwd grad scratches use += for K-loop accumulation; aux_vec_add cubin for stop-grad-lifted encoder accumulation.
fn main() { fn main() {
println!("cargo:rerun-if-changed=build.rs"); println!("cargo:rerun-if-changed=build.rs");

View File

@@ -132,6 +132,15 @@ extern "C" __global__ void aux_heads_fwd(
// No atomicAdd; no cross-thread reduction needed for grad_w / grad_h_aux // No atomicAdd; no cross-thread reduction needed for grad_w / grad_h_aux
// because each thread owns disjoint output slots (per // because each thread owns disjoint output slots (per
// `feedback_no_atomicadd.md`). // `feedback_no_atomicadd.md`).
//
// Accumulation semantics (SDD-3 Layer B5 callers): grad_w / grad_b are
// `+=` accumulated; grad_h_aux is `=` overwrite (per-K consumer downstream).
// The trainer's per-K backward loop launches this kernel multiple times
// over the K axis (gradient at each step k re-enters the same head with
// a different h_aux), and the per-batch param-grad sum across K is the
// correct gradient. Caller is responsible for zeroing the grad_w / grad_b
// scratches before the first K-iteration (the trainer does this with the
// rest of its per-step scratch memsets).
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
extern "C" __global__ void aux_heads_bwd( extern "C" __global__ void aux_heads_bwd(
const float* __restrict__ w_long, // [N_AUX_HORIZONS × AUX_HIDDEN] const float* __restrict__ w_long, // [N_AUX_HORIZONS × AUX_HIDDEN]
@@ -166,15 +175,19 @@ extern "C" __global__ void aux_heads_bwd(
} }
__syncthreads(); __syncthreads();
// grad_b_{long,short}[batch, k] = grad_y_{long,short}_hat[batch, k] // grad_b_{long,short}[batch, k] += grad_y_{long,short}_hat[batch, k]
// Sole writer: thread c == k (for k in [0, N_AUX_HORIZONS)). // Sole writer: thread c == k (for k in [0, N_AUX_HORIZONS)).
// `+=` per K-loop iteration sums each step's bias gradient.
if (c < N_AUX_HORIZONS) { if (c < N_AUX_HORIZONS) {
grad_b_long [batch * N_AUX_HORIZONS + c] = s_gyl[c]; grad_b_long [batch * N_AUX_HORIZONS + c] += s_gyl[c];
grad_b_short[batch * N_AUX_HORIZONS + c] = s_gys[c]; grad_b_short[batch * N_AUX_HORIZONS + c] += s_gys[c];
} }
// For thread c (the hidden-unit dim) — write grad_w[batch, k, c] for // For thread c (the hidden-unit dim) — accumulate grad_w[batch, k, c]
// each k, and accumulate grad_h_aux[batch, c] across both directions. // for each k (per-step h_aux varies across the K-loop), and write
// grad_h_aux[batch, c] (consumed immediately by the per-step
// aux_trunk_bwd, so OVERWRITE semantics — the K-loop doesn't read it
// across iterations).
const float h_c = s_h[c]; const float h_c = s_h[c];
float gh_acc = 0.0f; float gh_acc = 0.0f;
#pragma unroll #pragma unroll
@@ -182,12 +195,12 @@ extern "C" __global__ void aux_heads_bwd(
const float gyl_k = s_gyl[k]; const float gyl_k = s_gyl[k];
const float gys_k = s_gys[k]; const float gys_k = s_gys[k];
// grad_w_d[batch, k, c] = grad_y_d_hat[batch, k] * h_aux[batch, c] // grad_w_d[batch, k, c] += grad_y_d_hat[batch, k] * h_aux[batch, c]
const long long off = const long long off =
(long long)batch * N_AUX_HORIZONS * AUX_HIDDEN (long long)batch * N_AUX_HORIZONS * AUX_HIDDEN
+ (long long)k * AUX_HIDDEN + c; + (long long)k * AUX_HIDDEN + c;
grad_w_long [off] = gyl_k * h_c; grad_w_long [off] += gyl_k * h_c;
grad_w_short[off] = gys_k * h_c; grad_w_short[off] += gys_k * h_c;
// grad_h_aux[batch, c] += grad_y_d_hat[k] * W_d[k, c] for both dirs. // grad_h_aux[batch, c] += grad_y_d_hat[k] * W_d[k, c] for both dirs.
gh_acc += gyl_k * w_long [k * AUX_HIDDEN + c]; gh_acc += gyl_k * w_long [k * AUX_HIDDEN + c];

View File

@@ -109,9 +109,16 @@ extern "C" __global__ void aux_trunk_fwd(
// block = (AUX_HIDDEN = 64, 1, 1) // block = (AUX_HIDDEN = 64, 1, 1)
// shared_mem_bytes = (feat_dim + AUX_HIDDEN) * sizeof(float) // shared_mem_bytes = (feat_dim + AUX_HIDDEN) * sizeof(float)
// //
// Per-batch grad slices are written by this kernel (OVERWRITE semantics); // Per-batch grad slices accumulation semantics (SDD-3 Layer B5 callers):
// cross-batch reduction is the caller's responsibility via existing // * grad_w_in / grad_w_rec / grad_b / grad_tau — `+=` per K-loop launch.
// `reduce_axis0` infrastructure. // Multiple K-iterations sum into the same per-batch scratch (each step
// has different x/h_old → different contribution), and reduce_axis0
// at end of step collapses across B.
// * grad_h_old / grad_x — `=` overwrite (consumed immediately by the
// caller after each per-step launch: grad_h_old carries into k-1,
// grad_x is accumulated into the encoder grad slot or discarded).
// Caller must zero the four param-grad scratches at step start (the
// trainer does this alongside the rest of its per-step scratch memsets).
// //
// grad shapes: // grad shapes:
// grad_w_in : [B × AUX_HIDDEN × feat_dim] per-batch scratch // grad_w_in : [B × AUX_HIDDEN × feat_dim] per-batch scratch
@@ -190,30 +197,35 @@ extern "C" __global__ void aux_trunk_bwd(
// Per-batch grad-scratch writes. Each thread (batch, c) is the sole // Per-batch grad-scratch writes. Each thread (batch, c) is the sole
// writer of grad_*[batch, c, ...] — no race, no atomicAdd. // writer of grad_*[batch, c, ...] — no race, no atomicAdd.
grad_b[batch * AUX_HIDDEN + c] = d_pre; // Param-grad scratches use `+=` so the trainer's reverse-K loop sums
// contributions across all K positions (each with different x/h_old
// through the same params).
grad_b[batch * AUX_HIDDEN + c] += d_pre;
// grad_tau receives 0 when tau hits the clamp floor // grad_tau receives 0 when tau hits the clamp floor
// (strict d(max)/d(tau) = 0 below tau_eps). // (strict d(max)/d(tau) = 0 below tau_eps).
const float gate = (tau_raw > tau_eps) ? 1.0f : 0.0f; const float gate = (tau_raw > tau_eps) ? 1.0f : 0.0f;
grad_tau[batch * AUX_HIDDEN + c] = grad_tau[batch * AUX_HIDDEN + c] +=
gate * d_decay * decay * dt / (tau_eff * tau_eff); gate * d_decay * decay * dt / (tau_eff * tau_eff);
// grad_w_in[batch, c, k] = d_pre * x_local[k] // grad_w_in[batch, c, k] += d_pre * x_local[k]
for (int k = 0; k < feat_dim; ++k) { for (int k = 0; k < feat_dim; ++k) {
const long long off = const long long off =
(long long)batch * AUX_HIDDEN * feat_dim (long long)batch * AUX_HIDDEN * feat_dim
+ (long long)c * feat_dim + k; + (long long)c * feat_dim + k;
grad_w_in[off] = d_pre * x_local[k]; grad_w_in[off] += d_pre * x_local[k];
} }
// grad_w_rec[batch, c, k] = d_pre * h_old_local[k] // grad_w_rec[batch, c, k] += d_pre * h_old_local[k]
for (int k = 0; k < AUX_HIDDEN; ++k) { for (int k = 0; k < AUX_HIDDEN; ++k) {
const long long off = const long long off =
(long long)batch * AUX_HIDDEN * AUX_HIDDEN (long long)batch * AUX_HIDDEN * AUX_HIDDEN
+ (long long)c * AUX_HIDDEN + k; + (long long)c * AUX_HIDDEN + k;
grad_w_rec[off] = d_pre * h_old_local[k]; grad_w_rec[off] += d_pre * h_old_local[k];
} }
// grad_h_old[batch, c] direct decay contribution. // grad_h_old[batch, c] direct decay contribution. OVERWRITE — the
// trainer's per-step DtoD copy reads this immediately into the next
// K iteration's carry buffer; no cross-K accumulation here.
// (Cross-channel term Σ_j d_pre[j] * W_rec[j, c] is left for the // (Cross-channel term Σ_j d_pre[j] * W_rec[j, c] is left for the
// caller to add if BPTT through h_old is needed — for K=1 per-step // caller to add if BPTT through h_old is needed — for K=1 per-step
// aux supervision this direct term is sufficient.) // aux supervision this direct term is sufficient.)

View File

@@ -0,0 +1,38 @@
// SDD-3 Layer B5 — aux→encoder gradient accumulator.
//
// Single-purpose kernel that performs an element-wise += of a source
// vector into a destination vector. Used by `PerceptionTrainer` to fold
// the auxiliary trunk's `grad_x` (computed at each K-step of the
// reverse-order aux backward) into the main `grad_h_enriched_seq_t_d`
// slot when the asymmetric stop-grad is LIFTED (E3 design memo).
//
// Why a dedicated kernel rather than reusing an existing add primitive:
// * The other ml-alpha kernels that mutate `grad_h_enriched_seq` write
// OVERWRITE-style (cfc_step_bwd produces the slot's gradient by
// itself). Adding aux on top requires an independent += launch.
// * Per `feedback_no_atomicadd.md` — this is purely position-local
// (one thread per (b, c)) so no atomics; the destination slot is
// write-once per launch.
//
// Buffer contract:
// * `dst` — `[n]` destination, accumulated in-place: `dst[i] += src[i]`
// * `src` — `[n]` source (read-only)
// * `n` — element count
//
// Launch: grid=(ceil(n/256), 1, 1), block=(256, 1, 1), shared=0.
//
// Constraints honoured:
// * `feedback_no_atomicadd.md` — single-thread-per-element write.
// * `pearl_no_host_branches_in_captured_graph` — the captured graph's
// scalar `n` is bound at capture time; replay reads the same value.
// * `feedback_no_nvrtc.md` — pre-compiled cubin via build.rs.
extern "C" __global__ void aux_vec_add_inplace(
float* __restrict__ dst,
const float* __restrict__ src,
int n
) {
const int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i >= n) return;
dst[i] += src[i];
}

View File

@@ -23,6 +23,7 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use clap::Parser; use clap::Parser;
use ml_alpha::aux_heads::N_AUX_HORIZONS;
use ml_alpha::cfc::snap_features::Mbp10RawInput; use ml_alpha::cfc::snap_features::Mbp10RawInput;
use ml_alpha::data::loader::{ use ml_alpha::data::loader::{
MultiHorizonLoader, MultiHorizonLoaderConfig, DEFAULT_OUTCOME_LABEL_COST_ES, MultiHorizonLoader, MultiHorizonLoaderConfig, DEFAULT_OUTCOME_LABEL_COST_ES,
@@ -304,6 +305,11 @@ fn main() -> Result<()> {
smoothness_base_lambda: cli.smoothness_base_lambda, smoothness_base_lambda: cli.smoothness_base_lambda,
kernel_step_trace_path: cli.kernel_step_trace.clone(), kernel_step_trace_path: cli.kernel_step_trace.clone(),
bucket_warmup_cap_override: cli.bucket_warmup_cap_steps, bucket_warmup_cap_override: cli.bucket_warmup_cap_steps,
// SDD-3 Layer B5: aux supervision uses lr_cfc as default sentinel
// for now; per `pearl_adam_normalizes_loss_weights` aux has its own
// Adam group so this is a real lever — expose a CLI flag in a
// follow-up once we have a smoke-validated value.
lr_aux: cli.lr_cfc,
}; };
let mut trainer = PerceptionTrainer::new(&dev, &trainer_cfg).context("trainer init")?; let mut trainer = PerceptionTrainer::new(&dev, &trainer_cfg).context("trainer init")?;
@@ -463,6 +469,12 @@ fn main() -> Result<()> {
// don't pollute the batch. // don't pollute the batch.
let mut snap_batch: Vec<Vec<Mbp10RawInput>> = Vec::with_capacity(cli.batch_size); 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); 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]>> =
Vec::with_capacity(cli.batch_size);
let mut out_short_batch: Vec<Vec<[f32; N_AUX_HORIZONS]>> =
Vec::with_capacity(cli.batch_size);
while let Some(seq) = train_loader.next_sequence().context("train next_seq")? { 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 labels_per_pos: Vec<[f32; N_HORIZONS]> = Vec::with_capacity(seq.snapshots.len());
let mut any_finite = false; let mut any_finite = false;
@@ -472,8 +484,27 @@ fn main() -> Result<()> {
labels_per_pos.push(row); labels_per_pos.push(row);
} }
if !any_finite { continue; } 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]> =
Vec::with_capacity(seq.snapshots.len());
let mut out_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);
}
snap_batch.push(seq.snapshots); snap_batch.push(seq.snapshots);
label_batch.push(labels_per_pos); label_batch.push(labels_per_pos);
out_long_batch.push(out_long_pos);
out_short_batch.push(out_short_pos);
if snap_batch.len() < cli.batch_size { continue; } if snap_batch.len() < cli.batch_size { continue; }
// Full batch ready — apply LR schedule, fire step. // Full batch ready — apply LR schedule, fire step.
@@ -481,10 +512,20 @@ fn main() -> Result<()> {
let lr_m2_now = lr_schedule(cli.lr_mamba2, lr_min_m2, train_steps, cli.lr_warmup_steps, total_steps_budget); let lr_m2_now = lr_schedule(cli.lr_mamba2, lr_min_m2, train_steps, cli.lr_warmup_steps, total_steps_budget);
trainer.set_lr_cfc(lr_cfc_now); trainer.set_lr_cfc(lr_cfc_now);
trainer.set_lr_mamba2(lr_m2_now); trainer.set_lr_mamba2(lr_m2_now);
// SDD-3 Layer B5: aux LR follows main `lr_cfc` schedule for
// now. A dedicated aux schedule could land later once the
// initial smoke validates the dynamics.
trainer.set_lr_aux(lr_cfc_now);
let snap_refs: Vec<&[Mbp10RawInput]> = snap_batch.iter().map(|v| v.as_slice()).collect(); 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 label_refs: Vec<&[[f32; N_HORIZONS]]> = label_batch.iter().map(|v| v.as_slice()).collect();
let loss = trainer.step_batched(&snap_refs, &label_refs).context("train step_batched")?; 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 loss = trainer
.step_batched(&snap_refs, &label_refs, &out_long_refs, &out_short_refs)
.context("train step_batched")?;
epoch_train_loss += loss; epoch_train_loss += loss;
epoch_train_steps += 1; epoch_train_steps += 1;
train_loss_running += loss; train_loss_running += loss;
@@ -504,6 +545,8 @@ fn main() -> Result<()> {
} }
snap_batch.clear(); snap_batch.clear();
label_batch.clear(); label_batch.clear();
out_long_batch.clear();
out_short_batch.clear();
} }
let epoch_avg = if epoch_train_steps > 0 { let epoch_avg = if epoch_train_steps > 0 {
epoch_train_loss / epoch_train_steps as f32 epoch_train_loss / epoch_train_steps as f32

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@
//! full forward + backward (Mamba2 + CfC + heads) wires up correctly //! full forward + backward (Mamba2 + CfC + heads) wires up correctly
//! end-to-end and that all 6 AdamW optimizers actually move weights. //! end-to-end and that all 6 AdamW optimizers actually move weights.
use ml_alpha::aux_heads::N_AUX_HORIZONS;
use ml_alpha::cfc::snap_features::Mbp10RawInput; use ml_alpha::cfc::snap_features::Mbp10RawInput;
use ml_alpha::heads::N_HORIZONS; use ml_alpha::heads::N_HORIZONS;
use ml_alpha::trainer::perception::{PerceptionTrainer, PerceptionTrainerConfig}; use ml_alpha::trainer::perception::{PerceptionTrainer, PerceptionTrainerConfig};
@@ -54,6 +55,30 @@ fn synthetic_seq(
(out, labels) (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.
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)
}
#[test] #[test]
fn stacked_trainer_constructs_cleanly() { fn stacked_trainer_constructs_cleanly() {
let dev = test_device(); let dev = test_device();
@@ -76,8 +101,10 @@ fn stacked_trainer_loss_shrinks_on_constant_signal() {
smoothness_base_lambda: 0.0, smoothness_base_lambda: 0.0,
kernel_step_trace_path: None, kernel_step_trace_path: None,
bucket_warmup_cap_override: None, bucket_warmup_cap_override: None,
lr_aux: 3e-3,
}; };
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init"); let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
let (out_long, out_short) = synthetic_aux_outcomes(cfg.seq_len);
// Initial loss over 8 batches. // Initial loss over 8 batches.
let mut initial_total = 0.0_f32; let mut initial_total = 0.0_f32;
@@ -85,7 +112,7 @@ fn stacked_trainer_loss_shrinks_on_constant_signal() {
let mut prev_mid = 5500.0_f32; let mut prev_mid = 5500.0_f32;
for _ in 0..8 { for _ in 0..8 {
let (seq, labels) = synthetic_seq(cfg.seq_len, prev_mid, ts); let (seq, labels) = synthetic_seq(cfg.seq_len, prev_mid, ts);
let l = trainer.step(&seq, labels.as_slice()).expect("step warm"); let l = trainer.step(&seq, labels.as_slice(), out_long.as_slice(), out_short.as_slice()).expect("step warm");
initial_total += l; initial_total += l;
prev_mid = 0.5 * (seq.last().unwrap().bid_px[0] + seq.last().unwrap().ask_px[0]); prev_mid = 0.5 * (seq.last().unwrap().bid_px[0] + seq.last().unwrap().ask_px[0]);
ts = seq.last().unwrap().ts_ns; ts = seq.last().unwrap().ts_ns;
@@ -98,7 +125,7 @@ fn stacked_trainer_loss_shrinks_on_constant_signal() {
let mut window_count = 0usize; let mut window_count = 0usize;
for step_idx in 0..250 { for step_idx in 0..250 {
let (seq, labels) = synthetic_seq(cfg.seq_len, prev_mid, ts); let (seq, labels) = synthetic_seq(cfg.seq_len, prev_mid, ts);
let l = trainer.step(&seq, labels.as_slice()).expect("train step"); let l = trainer.step(&seq, labels.as_slice(), out_long.as_slice(), out_short.as_slice()).expect("train step");
window_loss += l; window_loss += l;
window_count += 1; window_count += 1;
if step_idx % 50 == 49 { if step_idx % 50 == 49 {
@@ -118,7 +145,7 @@ fn stacked_trainer_loss_shrinks_on_constant_signal() {
let mut final_total = 0.0_f32; let mut final_total = 0.0_f32;
for _ in 0..8 { for _ in 0..8 {
let (seq, labels) = synthetic_seq(cfg.seq_len, prev_mid, ts); let (seq, labels) = synthetic_seq(cfg.seq_len, prev_mid, ts);
let l = trainer.step(&seq, labels.as_slice()).expect("step final"); let l = trainer.step(&seq, labels.as_slice(), out_long.as_slice(), out_short.as_slice()).expect("step final");
final_total += l; final_total += l;
prev_mid = 0.5 * (seq.last().unwrap().bid_px[0] + seq.last().unwrap().ask_px[0]); prev_mid = 0.5 * (seq.last().unwrap().bid_px[0] + seq.last().unwrap().ask_px[0]);
ts = seq.last().unwrap().ts_ns; ts = seq.last().unwrap().ts_ns;
@@ -152,15 +179,17 @@ fn stacked_trainer_loss_shrinks_with_stride_4() {
smoothness_base_lambda: 0.0, smoothness_base_lambda: 0.0,
kernel_step_trace_path: None, kernel_step_trace_path: None,
bucket_warmup_cap_override: None, bucket_warmup_cap_override: None,
lr_aux: 3e-3,
}; };
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init"); let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
let (out_long, out_short) = synthetic_aux_outcomes(cfg.seq_len);
let mut initial = 0.0_f32; let mut initial = 0.0_f32;
let mut ts = 1_000_000u64; let mut ts = 1_000_000u64;
let mut prev_mid = 5500.0_f32; let mut prev_mid = 5500.0_f32;
for _ in 0..8 { for _ in 0..8 {
let (seq, labels) = synthetic_seq(cfg.seq_len, prev_mid, ts); let (seq, labels) = synthetic_seq(cfg.seq_len, prev_mid, ts);
let l = trainer.step(&seq, labels.as_slice()).expect("step"); let l = trainer.step(&seq, labels.as_slice(), out_long.as_slice(), out_short.as_slice()).expect("step");
initial += l; initial += l;
prev_mid = 0.5 * (seq.last().unwrap().bid_px[0] + seq.last().unwrap().ask_px[0]); prev_mid = 0.5 * (seq.last().unwrap().bid_px[0] + seq.last().unwrap().ask_px[0]);
ts = seq.last().unwrap().ts_ns; ts = seq.last().unwrap().ts_ns;
@@ -170,7 +199,7 @@ fn stacked_trainer_loss_shrinks_with_stride_4() {
for _ in 0..200 { for _ in 0..200 {
let (seq, labels) = synthetic_seq(cfg.seq_len, prev_mid, ts); let (seq, labels) = synthetic_seq(cfg.seq_len, prev_mid, ts);
trainer.step(&seq, labels.as_slice()).expect("step"); trainer.step(&seq, labels.as_slice(), out_long.as_slice(), out_short.as_slice()).expect("step");
prev_mid = 0.5 * (seq.last().unwrap().bid_px[0] + seq.last().unwrap().ask_px[0]); prev_mid = 0.5 * (seq.last().unwrap().bid_px[0] + seq.last().unwrap().ask_px[0]);
ts = seq.last().unwrap().ts_ns; ts = seq.last().unwrap().ts_ns;
} }
@@ -178,7 +207,7 @@ fn stacked_trainer_loss_shrinks_with_stride_4() {
let mut final_loss = 0.0_f32; let mut final_loss = 0.0_f32;
for _ in 0..8 { for _ in 0..8 {
let (seq, labels) = synthetic_seq(cfg.seq_len, prev_mid, ts); let (seq, labels) = synthetic_seq(cfg.seq_len, prev_mid, ts);
final_loss += trainer.step(&seq, labels.as_slice()).expect("step"); final_loss += trainer.step(&seq, labels.as_slice(), out_long.as_slice(), out_short.as_slice()).expect("step");
prev_mid = 0.5 * (seq.last().unwrap().bid_px[0] + seq.last().unwrap().ask_px[0]); prev_mid = 0.5 * (seq.last().unwrap().bid_px[0] + seq.last().unwrap().ask_px[0]);
ts = seq.last().unwrap().ts_ns; ts = seq.last().unwrap().ts_ns;
} }
@@ -208,6 +237,7 @@ fn stacked_trainer_loss_shrinks_at_batch_32() {
smoothness_base_lambda: 0.0, smoothness_base_lambda: 0.0,
kernel_step_trace_path: None, kernel_step_trace_path: None,
bucket_warmup_cap_override: None, bucket_warmup_cap_override: None,
lr_aux: 3e-3,
}; };
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init"); let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
@@ -228,13 +258,27 @@ fn stacked_trainer_loss_shrinks_at_batch_32() {
} }
(seqs, labels) (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();
let mut initial = 0.0_f32; let mut initial = 0.0_f32;
for warmup in 0..4 { for warmup in 0..4 {
let (seqs, labels) = make_batch(&mut prev_mid, &mut ts_base, &cfg); let (seqs, labels) = make_batch(&mut prev_mid, &mut ts_base, &cfg);
let seq_refs: Vec<&[Mbp10RawInput]> = seqs.iter().map(|s| s.as_slice()).collect(); 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 lbl_refs: Vec<&[[f32; N_HORIZONS]]> = labels.iter().map(|l| l.as_slice()).collect();
let l = trainer.step_batched(&seq_refs, &lbl_refs).expect("warm step"); let l = trainer
.step_batched(&seq_refs, &lbl_refs, &out_long_refs, &out_short_refs)
.expect("warm step");
if warmup >= 2 { if warmup >= 2 {
initial += l; initial += l;
} }
@@ -246,7 +290,9 @@ fn stacked_trainer_loss_shrinks_at_batch_32() {
let (seqs, labels) = make_batch(&mut prev_mid, &mut ts_base, &cfg); let (seqs, labels) = make_batch(&mut prev_mid, &mut ts_base, &cfg);
let seq_refs: Vec<&[Mbp10RawInput]> = seqs.iter().map(|s| s.as_slice()).collect(); 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 lbl_refs: Vec<&[[f32; N_HORIZONS]]> = labels.iter().map(|l| l.as_slice()).collect();
trainer.step_batched(&seq_refs, &lbl_refs).expect("train step"); trainer
.step_batched(&seq_refs, &lbl_refs, &out_long_refs, &out_short_refs)
.expect("train step");
} }
let mut final_loss = 0.0_f32; let mut final_loss = 0.0_f32;
@@ -254,7 +300,9 @@ fn stacked_trainer_loss_shrinks_at_batch_32() {
let (seqs, labels) = make_batch(&mut prev_mid, &mut ts_base, &cfg); let (seqs, labels) = make_batch(&mut prev_mid, &mut ts_base, &cfg);
let seq_refs: Vec<&[Mbp10RawInput]> = seqs.iter().map(|s| s.as_slice()).collect(); 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 lbl_refs: Vec<&[[f32; N_HORIZONS]]> = labels.iter().map(|l| l.as_slice()).collect();
final_loss += trainer.step_batched(&seq_refs, &lbl_refs).expect("final step"); final_loss += trainer
.step_batched(&seq_refs, &lbl_refs, &out_long_refs, &out_short_refs)
.expect("final step");
} }
final_loss /= 4.0; final_loss /= 4.0;
eprintln!("B=32 trainer: final={final_loss:.4}"); eprintln!("B=32 trainer: final={final_loss:.4}");
@@ -308,6 +356,7 @@ fn evaluate_alone_succeeds() {
smoothness_base_lambda: 0.0, smoothness_base_lambda: 0.0,
kernel_step_trace_path: None, kernel_step_trace_path: None,
bucket_warmup_cap_override: None, bucket_warmup_cap_override: None,
lr_aux: 3e-3,
}; };
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init"); let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
let ts = 1_000_000u64; let ts = 1_000_000u64;
@@ -338,15 +387,17 @@ fn evaluate_works_after_captured_training_step() {
smoothness_base_lambda: 0.0, smoothness_base_lambda: 0.0,
kernel_step_trace_path: None, kernel_step_trace_path: None,
bucket_warmup_cap_override: None, bucket_warmup_cap_override: None,
lr_aux: 3e-3,
}; };
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init"); let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
let (out_long, out_short) = synthetic_aux_outcomes(cfg.seq_len);
// Drive enough training steps to exercise warmup → capture → replay. // Drive enough training steps to exercise warmup → capture → replay.
let mut ts = 1_000_000u64; let mut ts = 1_000_000u64;
let mut prev_mid = 5500.0_f32; let mut prev_mid = 5500.0_f32;
for _ in 0..5 { for _ in 0..5 {
let (seq, labels) = synthetic_seq(cfg.seq_len, prev_mid, ts); let (seq, labels) = synthetic_seq(cfg.seq_len, prev_mid, ts);
trainer.step(&seq, labels.as_slice()).expect("train step"); trainer.step(&seq, labels.as_slice(), out_long.as_slice(), out_short.as_slice()).expect("train step");
prev_mid = 0.5 * (seq.last().unwrap().bid_px[0] + seq.last().unwrap().ask_px[0]); prev_mid = 0.5 * (seq.last().unwrap().bid_px[0] + seq.last().unwrap().ask_px[0]);
ts = seq.last().unwrap().ts_ns; ts = seq.last().unwrap().ts_ns;
} }
@@ -375,13 +426,15 @@ fn evaluate_works_after_capture_no_replay() {
smoothness_base_lambda: 0.0, smoothness_base_lambda: 0.0,
kernel_step_trace_path: None, kernel_step_trace_path: None,
bucket_warmup_cap_override: None, bucket_warmup_cap_override: None,
lr_aux: 3e-3,
}; };
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init"); let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
let (out_long, out_short) = synthetic_aux_outcomes(cfg.seq_len);
let ts = 1_000_000u64; let ts = 1_000_000u64;
let prev_mid = 5500.0_f32; let prev_mid = 5500.0_f32;
let (seq, labels) = synthetic_seq(cfg.seq_len, prev_mid, ts); let (seq, labels) = synthetic_seq(cfg.seq_len, prev_mid, ts);
trainer.step(&seq, labels.as_slice()).expect("warmup step"); trainer.step(&seq, labels.as_slice(), out_long.as_slice(), out_short.as_slice()).expect("warmup step");
trainer.step(&seq, labels.as_slice()).expect("capture step"); trainer.step(&seq, labels.as_slice(), out_long.as_slice(), out_short.as_slice()).expect("capture step");
// Eval right after capture, no replay. // Eval right after capture, no replay.
let (loss, _probs) = trainer.evaluate(&seq, labels.as_slice()).expect("eval after capture"); let (loss, _probs) = trainer.evaluate(&seq, labels.as_slice()).expect("eval after capture");
assert!(loss.is_finite(), "eval loss must be finite, got {loss}"); assert!(loss.is_finite(), "eval loss must be finite, got {loss}");
@@ -407,8 +460,10 @@ fn horizon_ema_and_lambda_track_after_training() {
smoothness_base_lambda: 0.0, smoothness_base_lambda: 0.0,
kernel_step_trace_path: None, kernel_step_trace_path: None,
bucket_warmup_cap_override: None, bucket_warmup_cap_override: None,
lr_aux: 3e-3,
}; };
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init"); let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
let (out_long, out_short) = synthetic_aux_outcomes(cfg.seq_len);
// EMA / lambda before any training step. // EMA / lambda before any training step.
let ema0 = trainer.loss_ema_snapshot().expect("ema initial"); let ema0 = trainer.loss_ema_snapshot().expect("ema initial");
@@ -420,7 +475,7 @@ fn horizon_ema_and_lambda_track_after_training() {
let mut prev_mid = 5500.0_f32; let mut prev_mid = 5500.0_f32;
for _ in 0..5 { for _ in 0..5 {
let (seq, labels) = synthetic_seq(cfg.seq_len, prev_mid, ts); let (seq, labels) = synthetic_seq(cfg.seq_len, prev_mid, ts);
trainer.step(&seq, labels.as_slice()).expect("train step"); trainer.step(&seq, labels.as_slice(), out_long.as_slice(), out_short.as_slice()).expect("train step");
prev_mid = 0.5 * (seq.last().unwrap().bid_px[0] + seq.last().unwrap().ask_px[0]); prev_mid = 0.5 * (seq.last().unwrap().bid_px[0] + seq.last().unwrap().ask_px[0]);
ts = seq.last().unwrap().ts_ns; ts = seq.last().unwrap().ts_ns;
} }
@@ -448,6 +503,88 @@ fn horizon_ema_and_lambda_track_after_training() {
"lambda mean {lam_mean} outside [1.0, 2.0] envelope"); "lambda mean {lam_mean} outside [1.0, 2.0] envelope");
} }
/// SDD-3 Layer B5: verifies the aux supervision Huber loss converges on
/// the same synthetic constant-direction up-ramp the BCE smoke uses.
/// Asserts:
/// * Aux per-horizon Huber EMA is finite and bounded after training.
/// * Aux per-horizon directional accuracy EMA is high (the synthetic
/// long > short separation is unambiguous).
/// * Asymmetric stop-grad to encoder either stays masked (Phase 1
/// auditor behaviour) OR lifts after the gates are satisfied —
/// either is a valid outcome on the small synthetic budget.
#[test]
fn stacked_trainer_aux_supervision_converges_on_constant_signal() {
let dev = test_device();
let cfg = PerceptionTrainerConfig {
seq_len: 16,
mamba2_state_dim: 8,
lr_cfc: 3e-3,
lr_mamba2: 1e-3,
seed: 0xA1B5,
horizon_weights: [1.0; N_HORIZONS],
n_batch: 1,
smoothness_base_lambda: 0.0,
kernel_step_trace_path: None,
bucket_warmup_cap_override: None,
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 mut ts = 1_000_000u64;
let mut prev_mid = 5500.0_f32;
// Train enough steps for the aux Huber + dir_acc EMAs to settle.
for _ in 0..400 {
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");
prev_mid = 0.5 * (seq.last().unwrap().bid_px[0] + seq.last().unwrap().ask_px[0]);
ts = seq.last().unwrap().ts_ns;
}
// After ≥400 steps:
// * Aux Huber EMA must be finite + small (we don't tie it to a
// specific threshold because the smoke is constant-signal +
// small budget; the assertion is that the kernel ran and the EMA
// did NOT diverge to NaN/Inf).
// * Per-horizon dir_acc EMA must exceed chance (0.5) on every
// horizon — the synthetic long/short separation is unambiguous,
// so a well-wired aux head should clear it easily.
for (h, &v) in trainer.aux_huber_ema_per_h.iter().enumerate() {
assert!(v.is_finite(), "aux_huber_ema_per_h[{h}] not finite: {v}");
assert!(v >= 0.0, "aux_huber_ema_per_h[{h}] negative: {v}");
}
for (h, &v) in trainer.aux_dir_acc_ema_per_h.iter().enumerate() {
assert!(v.is_finite(), "aux_dir_acc_ema_per_h[{h}] not finite: {v}");
assert!(
(0.0..=1.0).contains(&v),
"aux_dir_acc_ema_per_h[{h}] out of [0, 1]: {v}"
);
assert!(
v >= 0.5,
"aux_dir_acc_ema_per_h[{h}] = {v} below chance on unambiguous signal"
);
}
eprintln!(
"aux_huber_ema_per_h = {:?}",
trainer.aux_huber_ema_per_h
);
eprintln!(
"aux_dir_acc_ema_per_h = {:?}",
trainer.aux_dir_acc_ema_per_h
);
eprintln!(
"stop_grad_aux_to_encoder = {}",
trainer.stop_grad_aux_to_encoder
);
}
/// Does the bug surface after just ONE step (warmup only, no capture)? /// Does the bug surface after just ONE step (warmup only, no capture)?
/// If yes, the warmup dispatch path itself breaks subsequent eval. /// If yes, the warmup dispatch path itself breaks subsequent eval.
/// If no, the captured graph instantiation / launch is what breaks eval. /// If no, the captured graph instantiation / launch is what breaks eval.
@@ -465,13 +602,15 @@ fn evaluate_works_after_warmup_only() {
smoothness_base_lambda: 0.0, smoothness_base_lambda: 0.0,
kernel_step_trace_path: None, kernel_step_trace_path: None,
bucket_warmup_cap_override: None, bucket_warmup_cap_override: None,
lr_aux: 3e-3,
}; };
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init"); let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
let (out_long, out_short) = synthetic_aux_outcomes(cfg.seq_len);
let ts = 1_000_000u64; let ts = 1_000_000u64;
let prev_mid = 5500.0_f32; let prev_mid = 5500.0_f32;
let (seq, labels) = synthetic_seq(cfg.seq_len, prev_mid, ts); let (seq, labels) = synthetic_seq(cfg.seq_len, prev_mid, ts);
// ONLY one step — warmup, no capture yet. // ONLY one step — warmup, no capture yet.
trainer.step(&seq, labels.as_slice()).expect("warmup step"); trainer.step(&seq, labels.as_slice(), out_long.as_slice(), out_short.as_slice()).expect("warmup step");
// Now eval. // Now eval.
let (loss, _probs) = trainer.evaluate(&seq, labels.as_slice()).expect("eval after warmup"); let (loss, _probs) = trainer.evaluate(&seq, labels.as_slice()).expect("eval after warmup");
assert!(loss.is_finite(), "eval loss must be finite, got {loss}"); assert!(loss.is_finite(), "eval loss must be finite, got {loss}");