Files
foxhunt/docs/superpowers/plans/2026-05-21-crt-train-isv-driven-lambda-controller-plan.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

390 lines
18 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 ISV-Driven λ Controller — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Each task is a checkbox list of steps.
**Goal:** Replace static `SMOOTHNESS_LAMBDA_RATIO` with a signal-driven λ controller anchored on observed h30 jitter, with permanent floor.
**Architecture:** New `smoothness_lambda_controller.cu` kernel reads `raw_per_h` from `output_smoothness` (already emitted), maintains a Wiener-α-floor-0.5 EMA of jitter per horizon, derives per-horizon target as `jitter_ema[h30] × (K_h30 / K_h)`, and emits new `λ[h]` for next step's `output_smoothness` launch. λ floor = 1e-4 (permanent floor pattern). Launch order: ... → BCE → output_smoothness → smoothness_lambda_controller → backward K-loop.
**Tech Stack:** Same as the predecessor (Rust 1.85+, cudarc 0.19, CUDA 12.4, pre-compiled cubin).
**Predecessor commits (already shipped):** `bf5ecd109..70ecfc0fe` (the 10-commit static-λ chain).
---
## File structure (locked decisions)
| File | Action |
|------|--------|
| `crates/ml-alpha/cuda/smoothness_lambda_controller.cu` | CREATE |
| `crates/ml-alpha/build.rs` | Add `"smoothness_lambda_controller"` to `KERNELS`, bump v12→v13 |
| `crates/ml-alpha/src/heads.rs` | DELETE `SMOOTHNESS_LAMBDA_RATIO` |
| `crates/ml-alpha/src/trainer/perception.rs` | Add `jitter_ema_d`, `jitter_first_obs_d`, controller fn handle + module Arc; change λ init from `base × ratio[h]` to `[LAMBDA_FLOOR; 5]`; launch controller after `output_smoothness` in `step_batched`; remove `SMOOTHNESS_LAMBDA_RATIO` import |
| `crates/ml-alpha/tests/smoothness_lambda_controller_invariants.rs` | CREATE — 4 invariant tests |
---
## Task 1: Create kernel `smoothness_lambda_controller.cu`
- [ ] **1.1** Write `crates/ml-alpha/cuda/smoothness_lambda_controller.cu`:
```cuda
// smoothness_lambda_controller.cu — ISV-driven per-horizon λ for the
// output_smoothness regularizer.
//
// Reads `raw_per_h[5]` (emitted by output_smoothness_loss_and_grad),
// maintains a per-horizon Wiener-α-floor EMA of observed jitter,
// derives per-horizon target by anchoring on observed h30 jitter
// scaled by HORIZONS[0]/HORIZONS[h], and emits next-step λ[h] with a
// permanent floor.
//
// Per `pearl_controller_anchors_isv_driven`: target is signal-derived,
// not a constant. Per `pearl_first_observation_bootstrap`: first
// observation replaces EMA directly. Per
// `pearl_blend_formulas_must_have_permanent_floor`: λ has a permanent
// floor so the controller never fully self-disables. Per
// `pearl_wiener_alpha_floor_for_nonstationary`: α floored at 0.5 since
// the controller's target drifts as the policy co-adapts.
//
// Per `feedback_no_atomicadd`: 5 threads, single block, single writer
// per (h) slot; no atomics. Per `pearl_no_host_branches_in_captured_graph`:
// no host branching; thread-id gating only.
#define SLC_N_HORIZONS 5
#define SLC_LAMBDA_FLOOR 1.0e-4f
#define SLC_TARGET_EPS 1.0e-9f
#define SLC_ALPHA_FLOOR 0.5f
// HORIZONS = {30, 100, 300, 1000, 6000}.
// Ratios HORIZONS[0]/HORIZONS[h] = {1, 0.3, 0.1, 0.03, 0.005}.
// Constant array known at compile time.
__device__ __constant__ float TARGET_K_RATIO[SLC_N_HORIZONS] = {
1.0f,
30.0f / 100.0f,
30.0f / 300.0f,
30.0f / 1000.0f,
30.0f / 6000.0f,
};
extern "C" __global__ void smoothness_lambda_controller(
const float* __restrict__ raw_per_h, // [5] emitted by output_smoothness
float* __restrict__ jitter_ema, // [5] in/out — EMA state
int* __restrict__ first_obs, // [1] in/out — sentinel
float base_lambda, // scalar — amplitude knob
float* __restrict__ lambda_out // [5] output — λ for next step
) {
const int h = threadIdx.x;
if (h >= SLC_N_HORIZONS) return;
__shared__ float s_jitter_after[SLC_N_HORIZONS];
// Pass 1: EMA update with sentinel bootstrap.
const float raw_h = raw_per_h[h];
const int sentinel = first_obs[0];
float jitter_h;
if (sentinel == 0) {
jitter_h = raw_h;
} else {
jitter_h = (1.0f - SLC_ALPHA_FLOOR) * jitter_ema[h] + SLC_ALPHA_FLOOR * raw_h;
}
jitter_ema[h] = jitter_h;
s_jitter_after[h] = jitter_h;
// Single-writer of sentinel — thread h=0 only.
if (h == 0 && sentinel == 0) {
first_obs[0] = 1;
}
__syncthreads();
// Pass 2: derive target and update λ.
// target[h] = jitter_ema[0] * TARGET_K_RATIO[h]
// = jitter_ema[0] for h=0 (self-target)
// < jitter_ema[0] for h>0
const float jitter_h0 = s_jitter_after[0];
const float target_h = jitter_h0 * TARGET_K_RATIO[h];
// Excess controller: ratio - 1, clamped at zero (only push UP).
// When observed > target: excess > 0 → λ grows
// When observed ≤ target: excess = 0 → λ relaxes toward base_lambda × 1 = base_lambda
// Floor: λ ≥ LAMBDA_FLOOR.
const float safe_target = fmaxf(target_h, SLC_TARGET_EPS);
const float excess_ratio = fmaxf(0.0f, jitter_h / safe_target - 1.0f);
const float lambda_new = base_lambda * (1.0f + excess_ratio);
lambda_out[h] = fmaxf(SLC_LAMBDA_FLOOR, lambda_new);
}
```
- [ ] **1.2** Commit:
```bash
git add crates/ml-alpha/cuda/smoothness_lambda_controller.cu
git commit -m "feat(crt-train): add ISV-driven smoothness_lambda_controller kernel"
```
---
## Task 2: Register kernel in `build.rs`
- [ ] **2.1** Edit `crates/ml-alpha/build.rs`. In the `KERNELS` array, after `"output_smoothness"`, add `"smoothness_lambda_controller"` with comment. Bump cache-bust v12→v13:
```rust
"output_smoothness", // CRT.train: per-horizon adjacent-position prob-jitter penalty
"smoothness_lambda_controller", // CRT.train: ISV-driven λ controller anchored on h30 jitter
];
```
And replace the v12 comment with `// Cache bust v13 (2026-05-21): smoothness_lambda_controller.cu added — ISV-driven λ.`
- [ ] **2.2** Verify:
```bash
SQLX_OFFLINE=true CARGO_FEATURE_CUDA=1 cargo build -p ml-alpha 2>&1 | grep -E "smoothness_lambda_controller|^error" | head
```
Expected: `compiled cuda/smoothness_lambda_controller.cu -> .../smoothness_lambda_controller.cubin`. No errors.
- [ ] **2.3** Commit:
```bash
git add crates/ml-alpha/build.rs
git commit -m "build(crt-train): register smoothness_lambda_controller.cu in build.rs"
```
---
## Task 3: Delete `SMOOTHNESS_LAMBDA_RATIO` from `heads.rs`
- [ ] **3.1** Edit `crates/ml-alpha/src/heads.rs`. DELETE the entire `pub const SMOOTHNESS_LAMBDA_RATIO: [f32; N_HORIZONS] = [ ... ];` declaration (added in Task 3 of the predecessor plan, around lines 27-39). Also delete its doc-comment block.
- [ ] **3.2** This will break `perception.rs` import — that's expected; Task 4 below replaces the import.
- [ ] **3.3** Do NOT commit yet — Task 4 ships in the same commit (atomic refactor per `feedback_no_partial_refactor`).
---
## Task 4: Wire controller into `PerceptionTrainer` (perception.rs)
This is the largest task. It includes: new fields, new cubin/handle, new λ init pattern, controller launch in `step_batched`, removal of the static-ratio code.
- [ ] **4.1** In `crates/ml-alpha/src/trainer/perception.rs`:
a. **Remove** the `SMOOTHNESS_LAMBDA_RATIO` from the `use crate::heads::{...}` line (the import added in predecessor Task 5).
b. **Add** the controller cubin constant adjacent to `SMOOTHNESS_CUBIN`:
```rust
const SMOOTHNESS_CONTROLLER_CUBIN: &[u8] = include_bytes!(
concat!(env!("OUT_DIR"), "/smoothness_lambda_controller.cubin")
);
```
c. **Add** new struct fields adjacent to the existing `smoothness_*` fields (after `_smoothness_module`):
```rust
/// Per-horizon EMA of `raw_per_h` from output_smoothness. Updated
/// by `smoothness_lambda_controller` each step. Drives the
/// controller's target derivation.
smoothness_jitter_ema_d: CudaSlice<f32>,
/// First-observation sentinel (per pearl_first_observation_bootstrap).
/// 0 → next step bootstraps EMA from raw_per_h; 1 → Wiener-α update.
smoothness_jitter_first_obs_d: CudaSlice<i32>,
/// Cached handle for `smoothness_lambda_controller`.
smoothness_controller_fn: CudaFunction,
_smoothness_controller_module: Arc<CudaModule>,
```
d. **In the constructor**, after the existing smoothness module load, add the controller module load:
```rust
let smoothness_controller_module = ctx
.load_cubin(SMOOTHNESS_CONTROLLER_CUBIN.to_vec())
.context("load smoothness_lambda_controller cubin")?;
let smoothness_controller_fn = smoothness_controller_module
.load_function("smoothness_lambda_controller")
.context("load smoothness_lambda_controller")?;
```
e. **In the constructor**, **REPLACE** the existing λ init block (which computes `base × ratio[h]` per the predecessor Task 5) with a uniform-floor init:
```rust
// ── CRT.train: output-smoothness regularizer state ──
// λ is now ISV-driven by smoothness_lambda_controller — initialised
// to LAMBDA_FLOOR per horizon and updated each step inside the
// captured graph. The base_lambda amplitude knob enters the
// kernel as a scalar arg at launch time.
const LAMBDA_FLOOR_INIT: f32 = 1.0e-4;
let smoothness_lambda_host: Vec<f32> = vec![LAMBDA_FLOOR_INIT; N_HORIZONS];
let smoothness_lambda_d = {
let staging = unsafe { MappedF32Buffer::new(N_HORIZONS) }
.map_err(|e| anyhow::anyhow!("smoothness λ staging: {e}"))?;
staging.write_from_slice(&smoothness_lambda_host);
let mut dst = stream.alloc_zeros::<f32>(N_HORIZONS)
.context("smoothness_lambda_d alloc")?;
let nbytes = N_HORIZONS * std::mem::size_of::<f32>();
unsafe {
let (dst_ptr, _g) = dst.device_ptr_mut(&stream);
cudarc::driver::result::memcpy_dtod_async(
dst_ptr, staging.dev_ptr, nbytes, stream.cu_stream(),
).context("smoothness λ DtoD")?;
}
dst
};
let smoothness_loss_d = stream.alloc_zeros::<f32>(1)
.context("smoothness_loss_d alloc")?;
let smoothness_loss_host_d = unsafe { MappedF32Buffer::new(1) }
.map_err(|e| anyhow::anyhow!("smoothness_loss_host_d: {e}"))?;
let smoothness_loss_per_horizon_d = stream.alloc_zeros::<f32>(N_HORIZONS)
.context("smoothness_loss_per_horizon_d alloc")?;
let smoothness_loss_per_horizon_host_d = unsafe { MappedF32Buffer::new(N_HORIZONS) }
.map_err(|e| anyhow::anyhow!("smoothness_loss_per_horizon_host_d: {e}"))?;
// Controller state buffers.
let smoothness_jitter_ema_d = stream.alloc_zeros::<f32>(N_HORIZONS)
.context("smoothness_jitter_ema_d alloc")?;
let smoothness_jitter_first_obs_d = stream.alloc_zeros::<i32>(1)
.context("smoothness_jitter_first_obs_d alloc")?;
```
f. **In the `Ok(Self { ... })` block**, add the new controller fields (alphabetically near the other `smoothness_*` fields):
```rust
smoothness_jitter_ema_d,
smoothness_jitter_first_obs_d,
smoothness_controller_fn,
_smoothness_controller_module: smoothness_controller_module,
```
g. **In `step_batched`** (inside the captured-graph region), find the smoothness launch added in predecessor Task 6 (around line 1991-2008). IMMEDIATELY AFTER the smoothness launch block's closing brace, insert the controller launch:
```rust
// ── 5d. ISV-driven λ controller ──
// Reads the raw per-horizon mean-sq-diff just emitted by
// the output_smoothness kernel, updates jitter_ema, derives
// per-horizon target anchored on h30, and produces λ for
// the NEXT step's output_smoothness launch. Same captured
// graph; one-step delay between observation and effect
// (standard closed-loop pattern).
let base_lambda = self.cfg.smoothness_base_lambda;
let smooth_ctrl_cfg = LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (N_HORIZONS as u32, 1, 1),
shared_mem_bytes: 0,
};
{
let mut launch = self.stream.launch_builder(&self.smoothness_controller_fn);
launch
.arg(&self.smoothness_loss_per_horizon_d)
.arg(&mut self.smoothness_jitter_ema_d)
.arg(&mut self.smoothness_jitter_first_obs_d)
.arg(&base_lambda)
.arg(&mut self.smoothness_lambda_d);
unsafe { launch.launch(smooth_ctrl_cfg).context("smoothness_lambda_controller launch")?; }
}
```
- [ ] **4.2** Verify:
```bash
SQLX_OFFLINE=true cargo check -p ml-alpha --all-targets 2>&1 | grep -E "^error" | head
```
Expected: clean.
- [ ] **4.3** If you've removed `SMOOTHNESS_LAMBDA_RATIO` import successfully and the build still compiles, commit BOTH file changes atomically:
```bash
git add crates/ml-alpha/src/heads.rs crates/ml-alpha/src/trainer/perception.rs
git commit -m "feat(crt-train): wire ISV-driven λ controller; remove SMOOTHNESS_LAMBDA_RATIO"
```
---
## Task 5: Write controller invariant tests
- [ ] **5.1** Create `crates/ml-alpha/tests/smoothness_lambda_controller_invariants.rs`. Use the same upload/download helpers as `output_smoothness_grad_finite_diff.rs` (mapped-pinned + memcpy_dtod_async).
Tests to implement (4 #[test] functions):
1. `first_observation_bootstraps_ema_and_sets_floor`:
- Init `first_obs=0`, `jitter_ema=[0;5]`, raw_per_h=[0.5, 0.3, 0.1, 0.05, 0.01], base_lambda=0.001
- After kernel: `jitter_ema == raw_per_h`, `first_obs==1`, `λ[h] == LAMBDA_FLOOR` for all h (because excess_ratio = 0 on the bootstrap step: target = raw_per_h[0]*ratio[h], and jitter_ema[h] = raw_per_h[h] which is at-target when raw_per_h IS scaled by ratio; assert λ near LAMBDA_FLOOR or base_lambda, NOT runaway)
- (Actually on first observation, jitter_ema[h] = raw_per_h[h] for all h. target[h] = raw_per_h[0]*ratio[h]. excess_ratio[h] = (raw_per_h[h] / (raw_per_h[0]*ratio[h])) - 1. If the test setup has raw_per_h[h] = raw_per_h[0]*ratio[h], excess_ratio = 0 → λ[h] = max(LAMBDA_FLOOR, base_lambda). Use a test setup that satisfies this for clean assertion.)
2. `steady_state_at_target_yields_base_lambda`:
- Pre-set `first_obs=1`, `jitter_ema = [0.5, 0.15, 0.05, 0.015, 0.0025]` (= 0.5 × [1, 0.3, 0.1, 0.03, 0.005])
- raw_per_h same as above
- base_lambda = 0.01
- Expected: jitter_ema stays at target (Wiener-α update at α=0.5: same value), excess_ratio = 0, λ[h] = max(LAMBDA_FLOOR, 0.01) = 0.01 for all h
3. `excess_at_h6000_lifts_lambda_proportionally`:
- Pre-set `first_obs=1`, `jitter_ema = [0.5, 0.15, 0.05, 0.015, 0.025]` (h6000 at 10× target)
- raw_per_h same
- base_lambda = 0.01
- After kernel: jitter_ema[h6000] EMA update → 0.5*0.025 + 0.5*0.025 = 0.025 (raw matches old EMA, no change)
- Wait: if raw == old_ema, jitter_ema doesn't change. Use raw_per_h[h6000] = 0.025 (matches the pre-set ema)
- target[h6000] = jitter_ema_new[0]*0.005 = 0.5*0.005 = 0.0025
- excess_ratio = 0.025/0.0025 - 1 = 9.0
- λ[h6000] = 0.01 * (1 + 9) = 0.10
- Assert λ[h6000] is within 0.1 ± 0.005 (allowing for ema update on h0 if it shifts)
4. `lambda_floor_when_base_lambda_zero`:
- Pre-set `first_obs=1`, `jitter_ema = [0.5; 5]`, raw matching
- base_lambda = 0.0
- Expected: λ[h] = LAMBDA_FLOOR = 1e-4 for all h (max(1e-4, 0 * (1 + excess)) = 1e-4)
- [ ] **5.2** Build:
```bash
SQLX_OFFLINE=true cargo test -p ml-alpha --test smoothness_lambda_controller_invariants --no-run 2>&1 | tail
```
Expected: clean.
If local CUDA available:
```bash
SQLX_OFFLINE=true cargo test -p ml-alpha --test smoothness_lambda_controller_invariants -- --nocapture 2>&1 | tail -30
```
Expected: 4 tests pass.
- [ ] **5.3** Commit:
```bash
git add crates/ml-alpha/tests/smoothness_lambda_controller_invariants.rs
git commit -m "test(crt-train): invariant tests for smoothness_lambda_controller"
```
---
## Task 6: Push + validate
- [ ] **6.1** Run full check + perception_overfit:
```bash
cd /home/jgrusewski/Work/foxhunt
SQLX_OFFLINE=true cargo check -p ml-alpha --all-targets 2>&1 | tail
SQLX_OFFLINE=true cargo test -p ml-alpha --test perception_overfit -- --nocapture 2>&1 | tail -30 # if local CUDA
```
Expected: clean check; perception_overfit 9/9 (smoothness=0 base_lambda + LAMBDA_FLOOR = essentially-no-op).
- [ ] **6.2** Push:
```bash
git push origin ml-alpha-phase-a
```
- [ ] **6.3** Submit validation retrain at `smoothness-base-lambda=0.001` (the amplitude knob; the controller scales adaptively from there):
```bash
argo submit -n foxhunt --from=wftmpl/alpha-perception \
-p commit-sha=$(git rev-parse HEAD) \
-p git-branch=ml-alpha-phase-a \
-p gpu-pool=ci-training-l40s \
-p smoothness-base-lambda=0.001 \
-p n-train-seqs=8000 \
-p n-val-seqs=1000 \
-p epochs=5
```
Watch logs for `final_smooth_loss_per_horizon` in the summary JSON. Expected: h30 ≈ h6000_target × 200, h6000 ≈ h30 / 200 (controller pulls h6000 to its 200× lower target).
---
## Self-Review
1. Spec extension §3 mapped to Task 1 (kernel), Task 4 (wiring). ✓
2. Spec extension §3.4 removals mapped to Task 3 (heads.rs) + Task 4 (perception.rs constructor). ✓
3. Spec extension §5 invariants mapped to Task 5 (4 tests). ✓
4. No placeholders. No TODO markers. ✓
5. Atomic refactor: Task 4 ships heads.rs deletion + perception.rs wiring in ONE commit (per `feedback_no_partial_refactor`). ✓
6. Memory rules cited:
- `feedback_no_atomicadd` ✓ (5 threads, single writer per slot)
- `feedback_no_htod_htoh_only_mapped_pinned` ✓ (λ init still uses MappedF32Buffer)
- `feedback_no_nvrtc` ✓ (build.rs registration)
- `pearl_no_host_branches_in_captured_graph` ✓ (controller launch has no host branches)
- `pearl_first_observation_bootstrap` ✓ (sentinel pattern in kernel)
- `pearl_blend_formulas_must_have_permanent_floor` ✓ (LAMBDA_FLOOR is permanent)
- `pearl_wiener_alpha_floor_for_nonstationary` ✓ (α=0.5 floor)
- `pearl_controller_anchors_isv_driven` ✓ (target anchored on observed h30 jitter)
- `feedback_no_partial_refactor` ✓ (atomic delete+wire in Task 4)
Ready to execute.