# CRT.train Output-Smoothness — ISV-Driven λ Controller (Spec Extension) **Date:** 2026-05-21 **Branch context:** `ml-alpha-phase-a` (HEAD `70ecfc0fe`; static-λ implementation shipped in 10 commits) **Predecessor spec:** `2026-05-20-crt-train-output-smoothness-design.md` **Status:** Design — extends the predecessor by replacing the static `SMOOTHNESS_LAMBDA_RATIO` with a signal-driven controller anchored on observed h30 jitter. --- ## 1. Motivation The predecessor spec ships `λ[h] = smoothness_base_lambda × SMOOTHNESS_LAMBDA_RATIO[h]` where the ratio array `[1, 3.33, 10, 33.33, 200]` is a tuned constant. This violates two memory rules: - [pearl_controller_anchors_isv_driven](memory/pearl_controller_anchors_isv_driven.md) — every controller anchor/target/cap must be ISV-driven, not hardcoded. - [feedback_isv_for_adaptive_bounds](memory/feedback_isv_for_adaptive_bounds.md) — adaptive bounds live in ISV, not constants. The K-ratio assumption (h6000 should change 200× more slowly than h30) is *probably right* but the model's intrinsic noise (the observed h30 jitter) is what we should anchor on, not the abstract ratio of horizon lengths. ## 2. Hypothesis Anchor target per-horizon on the OBSERVED h30 jitter EMA, scale down per horizon by `K_h30 / K_h`. Drive λ[h] to push observed jitter toward target. Use a permanent floor so the controller never fully disables. ## 3. Architectural Intervention ### 3.1 State (new buffers on `PerceptionTrainer`) - `jitter_ema_d: CudaSlice` shape `[5]` — running EMA of `raw_per_h`. - `jitter_first_obs_d: CudaSlice` shape `[1]` — sentinel `0 → 1` on first observation (per [pearl_first_observation_bootstrap](memory/pearl_first_observation_bootstrap.md)). `smoothness_lambda_d` keeps its name but its semantics change: it is no longer uploaded once at construction; it is updated each step inside the captured graph. ### 3.2 New CUDA kernel `cuda/smoothness_lambda_controller.cu` ``` extern "C" __global__ void smoothness_lambda_controller( const float* raw_per_h, // [5] emitted by output_smoothness_loss_and_grad float* jitter_ema, // [5] in/out — EMA state int* first_obs, // [1] in/out — sentinel float base_lambda, // scalar — amplitude knob from cfg.smoothness_base_lambda float* lambda_out // [5] new λ values for the kernel's NEXT step ); ``` Math (per thread `h ∈ {0..4}`, single block of 5 threads): ``` // 1. EMA update with sentinel bootstrap. if (first_obs[0] == 0): jitter_ema[h] = raw_per_h[h] if (h == 0): first_obs[0] = 1 else: // Fixed-α floor at 0.5 (Wiener-α floor for non-stationary loops // per pearl_wiener_alpha_floor_for_nonstationary). The target // drifts as the policy co-adapts, so MSE-optimal α isn't right. jitter_ema[h] = 0.5 * jitter_ema[h] + 0.5 * raw_per_h[h] __syncthreads() // all threads need jitter_ema[0] for target derivation // 2. Target = ISV-anchored on observed h30 jitter. // target[0] = jitter_ema[0] ← h30 self-targets // target[h] = jitter_ema[0] * (HORIZONS[0] / HORIZONS[h]) for h ≥ 1 float target_h if (h == 0): target_h = jitter_ema[0] else: target_h = jitter_ema[0] * (HORIZONS[0] / HORIZONS[h]) // 3. λ controller: push UP when observed > target; relax to floor otherwise. float excess_ratio = max(0.0, jitter_ema[h] / max(target_h, 1e-9) - 1.0) float lambda_new = base_lambda * (1.0 + excess_ratio) lambda_out[h] = max(LAMBDA_FLOOR, lambda_new) ``` where `LAMBDA_FLOOR = 1e-4` — the permanent floor (per [pearl_blend_formulas_must_have_permanent_floor](memory/pearl_blend_formulas_must_have_permanent_floor.md): `max(real, floor)`, never blend). ### 3.3 Launch order in `step_batched` Replace the current sequence: ``` ... → BCE → output_smoothness → backward K-loop ``` with: ``` ... → BCE → output_smoothness → smoothness_lambda_controller → backward K-loop ``` The controller runs AFTER `output_smoothness` (which emits `raw_per_h`) and BEFORE the backward K-loop. The updated `smoothness_lambda_d` is used by the NEXT step's `output_smoothness` launch — one-step delay, standard closed-loop pattern. ### 3.4 Removals - `pub const SMOOTHNESS_LAMBDA_RATIO: [f32; N_HORIZONS]` in `heads.rs` — DELETED (no longer load-bearing; ratio is now derived from `HORIZONS`). - The constructor's λ pre-multiplication block in `perception.rs` — REPLACED with `λ_init = [LAMBDA_FLOOR; 5]` upload. The `cfg.smoothness_base_lambda` knob stays — it's the amplitude (controller equilibrium value when `excess_ratio == 0`). ## 4. Implementation Surface | File | Change | |------|--------| | `crates/ml-alpha/cuda/smoothness_lambda_controller.cu` | NEW | | `crates/ml-alpha/build.rs` | Add `"smoothness_lambda_controller"` to KERNELS, bump cache-bust v12 → v13 | | `crates/ml-alpha/src/heads.rs` | DELETE `SMOOTHNESS_LAMBDA_RATIO` const | | `crates/ml-alpha/src/trainer/perception.rs` | Add `jitter_ema_d`, `jitter_first_obs_d`, controller fn handle + module; init λ to LAMBDA_FLOOR; launch controller in `step_batched` | | `crates/ml-alpha/tests/output_smoothness_grad_finite_diff.rs` | NO change — existing tests still valid (they test the smoothness kernel; the controller is separate) | | `crates/ml-alpha/tests/smoothness_lambda_controller_invariants.rs` | NEW — invariant tests for the controller | The CLI flag `--smoothness-base-lambda` and the Argo plumbing stay unchanged. ## 5. Validation Gate Update Spec §5 MUST/WIN/STRETCH gates unchanged. ADD a controller-specific invariant test: **Controller MUST gates** (unit tests, no training run needed): 1. First-observation sentinel: `first_obs == 0` → kernel sets `jitter_ema = raw_per_h`, `first_obs = 1`, `λ = [LAMBDA_FLOOR; 5]`. 2. Steady-state symmetry: if `raw_per_h[h] / raw_per_h[0] == HORIZONS[0] / HORIZONS[h]` for all h, the controller emits `λ[h] = base_lambda` exactly (excess_ratio = 0 everywhere). 3. Excess response: if `jitter_ema[h6000]` is 10× its target, `λ[h6000] = base_lambda * 10`. 4. Floor invariant: when `base_lambda = 0`, `λ[h] = LAMBDA_FLOOR` for all h. The kernel does NOT collapse to producing zero loss + zero grad (because LAMBDA_FLOOR > 0); that's an intentional change vs the static-λ spec — the floor is a safety net. If you want the "static λ=0 is a no-op" property back (kernel literally produces nothing), set `LAMBDA_FLOOR = 0.0` — but then the controller can self-disable and that violates the user's "with floor" requirement. Resolution: `LAMBDA_FLOOR = 1e-4` chosen so the residual smoothing gradient is empirically negligible (~`2 × 1e-4 × (Δp)² / N_pairs` ≈ `1e-7` per gradient entry — well below f32 grad noise). ## 6. Open Questions 1. **First-observation sentinel race?** With 5 threads writing `first_obs` after `__syncthreads()`, only thread `h == 0` writes the sentinel — single writer, no race. Verify in code. 2. **Should `target_h` for h=0 always equal `jitter_ema[0]` (self-target, controller passive)?** Yes per design; h30 has no slower horizon to anchor on, so it tracks the amplitude knob directly. `excess_ratio[0] = 0` always. 3. **`max(target_h, 1e-9)` epsilon — is this a tuned constant?** Technically yes, but it's a denominator-safety epsilon, not a behavioral knob. Per existing codebase convention (e.g., `REGIME_EPS = 1e-6` in `data/loader.rs`), epsilon constants are exempt from the no-tuned-constants rule. ## 7. Backward Compatibility The CLI flag `--smoothness-base-lambda` stays — operator can still sweep it for amplitude. Default 0.0 used to be "kernel produces zero loss/grad"; now it means "controller maintains λ = LAMBDA_FLOOR for all h." Behaviorally near-equivalent (LAMBDA_FLOOR is tiny) but not literally zero. If literally-zero behavior is required, set `LAMBDA_FLOOR = 0.0` — but this conflicts with the user's "with floor" requirement. --- **End of spec extension. Awaiting plan and execution.**