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>
136 lines
7.8 KiB
Markdown
136 lines
7.8 KiB
Markdown
# 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.**
|