# 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<f32>` shape `[5]` — running EMA of `raw_per_h`.
- `jitter_first_obs_d: CudaSlice<i32>` 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.**
