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:
@@ -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");
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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.)
|
||||||
|
|||||||
38
crates/ml-alpha/cuda/aux_vec_add.cu
Normal file
38
crates/ml-alpha/cuda/aux_vec_add.cu
Normal 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];
|
||||||
|
}
|
||||||
@@ -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
@@ -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}");
|
||||||
|
|||||||
Reference in New Issue
Block a user