Files
foxhunt/crates/ml-alpha/tests/multi_horizon_loader.rs
jgrusewski 1bb878b6a7 wip(ml-alpha): PerceptionTrainer scaffold + rename drops phase_a naming
Per user feedback "no phase naming, give proper naming to files and
functions": renames src/trainer/phase_a.rs -> perception.rs,
src/data/phase_a_loader.rs -> data/loader.rs, and the corresponding
types (PhaseATrainer -> PerceptionTrainer, PhaseALoader ->
MultiHorizonLoader, PhaseAConfig -> MultiHorizonLoaderConfig,
PhaseASequence -> LabeledSequence). Test files renamed in lock-step.

Adds PerceptionTrainer.step() — full end-to-end forward + heads
backward + cfc_step_backward (K=1 truncated BPTT) + 5 AdamW param
groups (W_in, W_rec, b, heads_w, heads_b). reset_hidden_state()
zeros h_old between independent samples.

KNOWN ISSUE — synthetic-overfit smoke (tests/perception_overfit.rs)
does NOT yet show loss shrinkage on the 200-step budget:
  initial_avg=0.6914, final_avg=0.6955 (random-baseline ln(2)=0.693)

The kernels are individually correct (heads_bwd + cfc_bwd finite-diff
at 5% rel, AdamW invariant ‖θ‖ 40->1 in 200 steps). The end-to-end
chain doesn't converge — most likely due to half the CfC cells having
near-1 decay from log-uniform tau init on a zeroed hidden state, so
only the fast cells carry signal. Fix candidates for next session:
  - tau init narrower / per-task tuned for the smoke
  - longer step budget (1000+) with adjusted LR
  - validate end-to-end with explicit print of grad/probs across iters

Task 13 is NOT complete (the gate criterion isn't met). Subsequent
work continues from here.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 22:29:32 +02:00

67 lines
2.2 KiB
Rust

//! Phase A loader smoke tests.
//!
//! Real-data path is `--ignored`; runs only with FOXHUNT_TEST_DATA set.
use ml_alpha::data::loader::{MultiHorizonLoaderConfig, MultiHorizonLoader};
use std::path::PathBuf;
fn cfg_from_env() -> Option<MultiHorizonLoaderConfig> {
let root = std::env::var("FOXHUNT_TEST_DATA").ok()?;
let mbp10 = PathBuf::from(format!("{root}/mbp10"));
let predec = PathBuf::from(format!("{root}/predecoded"));
if !mbp10.exists() {
return None;
}
Some(MultiHorizonLoaderConfig {
mbp10_root: mbp10,
predecoded_dir: predec,
seq_len: 64,
horizons: [30, 100, 300, 1000, 6000],
n_max_sequences: 100,
seed: 0xA1A2_A3A4,
})
}
#[test]
#[ignore]
fn loader_yields_seq_with_valid_labels() {
let Some(cfg) = cfg_from_env() else {
eprintln!("skipping: FOXHUNT_TEST_DATA not set or mbp10 dir missing");
return;
};
let mut loader = MultiHorizonLoader::new(&cfg).expect("loader init");
assert!(loader.n_files() > 0, "no DBN files found in mbp10 root");
let mut count = 0;
while let Some(seq) = loader.next_sequence().expect("next") {
assert_eq!(seq.snapshots.len(), cfg.seq_len);
for h in 0..5 {
assert_eq!(seq.labels[h].len(), cfg.seq_len);
for &v in &seq.labels[h] {
assert!(v.is_nan() || v == 0.0 || v == 1.0, "label not binary: {v}");
}
}
let any_valid = seq.labels.iter().any(|l| l.iter().any(|v| !v.is_nan()));
assert!(any_valid, "expected at least one non-NaN label in a sequence");
count += 1;
if count >= 5 {
break;
}
}
assert!(count >= 5, "expected at least 5 sequences, got {count}");
}
#[test]
fn loader_errors_on_missing_root() {
let cfg = MultiHorizonLoaderConfig {
mbp10_root: PathBuf::from("/tmp/does_not_exist_foxhunt_test"),
predecoded_dir: PathBuf::from("/tmp/does_not_exist_foxhunt_test"),
seq_len: 64,
horizons: [30, 100, 300, 1000, 6000],
n_max_sequences: 10,
seed: 0,
};
let res = MultiHorizonLoader::new(&cfg);
assert!(res.is_err(), "expected error for missing mbp10 root");
}