Files
foxhunt/docs/superpowers/specs/2026-05-21-crt-train-isv-driven-lambda-controller.md
jgrusewski ed34d356a8 docs(per-horizon-cfc): kickoff — spec + plan + historical record
Spec: docs/superpowers/specs/2026-05-21-per-horizon-cfc-inference-design.md
Plan: docs/superpowers/plans/2026-05-21-per-horizon-cfc-inference-plan.md

Spec went through 2 critical-review passes (32 total findings, all resolved).
Bucketing source: CfC.tau (per-channel, trained, log-uniform init at HIDDEN_DIM=128).
Atomic refactor reverts MTER scaffolding. 5 ISV-driven controllers. All-on-device
transition (no bulk DtoH). Single fused per-branch kernel. Compact ragged heads_w_skip.
Validated via 3 Argo smokes (training stability, CRT.diag inference differentiation,
fxt-backtest end-to-end).

Also committing historical record: superseded MTER spec/plan, intervention A
plan, ISV λ controller spec/plan, GPU log ring spec/plan. Per spec §5.3 these
stay as audit trail; not in build path.

Plan has 18 tasks, full TDD with bite-sized steps, ready for
subagent-driven-development execution.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:05:28 +02:00

136 lines
7.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.**