diff --git a/crates/ml-alpha/cuda/horizon_lambda.cu b/crates/ml-alpha/cuda/horizon_lambda.cu index cb17837ed..f64ea0d7b 100644 --- a/crates/ml-alpha/cuda/horizon_lambda.cu +++ b/crates/ml-alpha/cuda/horizon_lambda.cu @@ -31,15 +31,24 @@ // a Phase 3 follow-up; for now a conservative 0.1 keeps the EMA // stable across training noise. // -// Lambda safety: clamped to `[LAMBDA_FLOOR, LAMBDA_CEILING]` per -// `pearl_audit_unboundedness_for_implicit_asymmetry.md` — the ratio -// loss_ema_h / mean_loss_ema is unbounded above when one horizon -// stalls. Capping at 2× prevents winner-take-all amplification per +// Lambda safety: ASYMMETRIC clamp `[1.0, LAMBDA_CEILING]` per +// `pearl_audit_unboundedness_for_implicit_asymmetry.md`. The lower +// bound is 1.0, not <1: the controller is boost-only — under-trained +// horizons get more trunk-gradient pull, but easy horizons are NEVER +// starved below uniform weight. The 3-fold CV that motivated this +// design (no-ISV @ 0.711 ± 0.014 vs ISV @ 0.720 ± 0.011 across +// folds 0/1/2) showed fold-1 ISV gaining +4.6pt on h6000 when +// h6000 was hardest, but fold-2 ISV LOSING -1.3pt on h6000 when it +// wasn't — because a symmetric clamp [0.5, 2.0] demoted h6000 below +// uniform when its EMA was below the cross-horizon mean. Asymmetric +// clamp preserves the gain without the regression. h6000 is the +// deployment horizon; we never want to starve it. Upper cap at 2× +// still prevents winner-take-all amplification per // `pearl_controller_amplifies_dominant_magnitude_trap.md`. #define N_HORIZONS_LAMBDA 5 #define ALPHA_FIXED 0.1f -#define LAMBDA_FLOOR 0.5f +#define LAMBDA_FLOOR 1.0f // boost-only: never demote a horizon below uniform #define LAMBDA_CEILING 2.0f extern "C" __global__ void horizon_ema_and_lambda( diff --git a/crates/ml-alpha/tests/perception_overfit.rs b/crates/ml-alpha/tests/perception_overfit.rs index 105d2e459..2240bc543 100644 --- a/crates/ml-alpha/tests/perception_overfit.rs +++ b/crates/ml-alpha/tests/perception_overfit.rs @@ -221,7 +221,9 @@ fn evaluate_works_after_capture_no_replay() { /// ISV-driven per-horizon EMA + lambda — after a few training steps, /// `loss_ema` should contain positive BCE values for every horizon /// (proves the BCE kernel writes the per-horizon channel correctly), -/// and `lambda` should land inside [0.5, 2.0] (clamp invariant). +/// and `lambda` should land inside the ASYMMETRIC clamp [1.0, 2.0] +/// (boost-only — never demote below uniform, per +/// `pearl_audit_unboundedness_for_implicit_asymmetry.md`). #[test] fn horizon_ema_and_lambda_track_after_training() { let dev = test_device(); @@ -260,16 +262,18 @@ fn horizon_ema_and_lambda_track_after_training() { assert!(v.is_finite(), "ema[{h}] not finite: {v}"); assert!(v > 0.0, "ema[{h}] should be positive, got {v}"); } - // Lambda clamp: every entry in [0.5, 2.0]. + // Lambda asymmetric clamp: every entry in [1.0, 2.0] (boost-only). for (h, &v) in lam.iter().enumerate() { assert!(v.is_finite(), "lambda[{h}] not finite: {v}"); - assert!(v >= 0.5 - 1e-6 && v <= 2.0 + 1e-6, - "lambda[{h}] = {v} outside clamp [0.5, 2.0]"); + assert!(v >= 1.0 - 1e-6 && v <= 2.0 + 1e-6, + "lambda[{h}] = {v} outside asymmetric clamp [1.0, 2.0]"); } - // Mean of lambda should be ≈ 1.0 (since lambda is ratio to mean, clamped). + // Mean of lambda must be >= 1.0 (boost-only, never demote). + // Upper-bounded by 2.0 (ceiling clamp). On synthetic-constant data + // the per-horizon EMAs converge, ratios approach 1.0, mean → 1.0. let lam_mean: f32 = lam.iter().sum::() / lam.len() as f32; - assert!((lam_mean - 1.0).abs() < 0.5, - "lambda mean {lam_mean} should be close to 1.0 (clamp envelope, ratio-of-mean)"); + assert!(lam_mean >= 1.0 - 1e-6 && lam_mean <= 2.0 + 1e-6, + "lambda mean {lam_mean} outside [1.0, 2.0] envelope"); } /// Does the bug surface after just ONE step (warmup only, no capture)?