diff --git a/docs/superpowers/plans/2026-05-20-crt-phase-a-continuous-controller.md b/docs/superpowers/plans/2026-05-20-crt-phase-a-continuous-controller.md index d2ffb7967..ccf94ec1f 100644 --- a/docs/superpowers/plans/2026-05-20-crt-phase-a-continuous-controller.md +++ b/docs/superpowers/plans/2026-05-20-crt-phase-a-continuous-controller.md @@ -17,18 +17,21 @@ ## Scope contract **In scope (Phase A):** -- Investigate `forward_only` cost model +- Investigate `forward_only` cost model (Task A0) +- If investigation reveals `forward_only` doesn't support incremental state advance, **refactor it to do so** (Task A0.5). Kernel changes acceptable — greenfields. - Remove `decision_stride` field (greenfields atomic) per spec §3.3 + §8 - Remove stride gate from `BacktestHarness::run` -- Minimal anti-hyperactivity smoothing (conviction-EMA in controller — small subset of spec §4.2 that ships now to keep Gate 1 measurable) +- Wiener-α conviction-EMA smoothing — a load-bearing component of continuous control, not a hedge. Without smoothing, event-rate trading is incoherent (alpha jitters → target oscillates → spread bleed). Spec §4.2's EMA formula ships in Phase A. - Cluster smoke validation against Gate 1 acceptance criteria -**Out of scope (deferred to Phase B/C/D plans):** -- Full multi-horizon conviction formula (spec §4.4 — only the EMA smoothing of the existing scalar conviction is in Phase A) -- Conviction-degradation exit (spec §4.3) -- Adaptive envelope/threshold/vol-target (spec §5) -- Online weight adaptation (spec §6) -- `open_trade_state` 24→64 byte expansion (spec §7) +**Out of scope (deferred to Phase B):** +- Full multi-horizon conviction *formula* — spec §4.4's per-horizon ISV-weighted aggregation. Phase A uses the existing scalar max-conviction-across-horizons as the EMA input; Phase B replaces the scalar with the proper multi-horizon weighted aggregation. +- Conviction-degradation composite exit (spec §4.3) +- `open_trade_state` 24→64 byte expansion (spec §7) — Phase B introduces the trajectory fields needed by §4.3 exit logic +- Adaptive envelope/threshold/vol-target (spec §5) — Phase C +- Online weight adaptation (spec §6) — Phase D + +**Distinction**: Phase A ships the *continuous control loop* with proper smoothing on the existing scalar conviction. Phase B ships the *multi-horizon signal-driven policy* that the spec describes — that's a different code path that consumes the smoothed conviction differently. The split is by code-path responsibility, not by "shipping less to be safe." --- @@ -97,10 +100,10 @@ Create `docs/superpowers/memos/2026-05-20-crt-a-forward-cost-investigation.md` w - Which case (1/2/3) applies - The relevant files and key functions - Estimated cost ratio -- Recommended Phase A implementation path: - - **If Case 1:** simple — Phase A just removes the stride gate. Cost target ≤ 2× should hold. - - **If Case 2:** Phase A needs `forward_only_incremental` first (advance trunk state by 1 step using cached prior state). This becomes Task A1.5 added to this plan. - - **If Case 3:** Phase A needs to relax cost target OR add state caching. Recommend approach. +- The required work to make Phase A feasible at ≤ 2× wall-time: + - **Case 1** (stateful, incremental): no extra work. Proceed to Task A1. + - **Case 2** (stateless K-window): Task A0.5 fires — refactor `forward_only` to maintain persistent SSM state, expose `forward_step` that advances state by 1 event. Kernel changes acceptable. + - **Case 3** (stateful with periodic reset): Task A0.5 fires — remove or extend the reset interval to support continuous operation. Document the new state model. - [ ] **Step 5: Commit the memo** @@ -109,7 +112,103 @@ git add docs/superpowers/memos/2026-05-20-crt-a-forward-cost-investigation.md git commit -m "memo(crt-a): forward pass cost investigation — chose [Case 1/2/3]" ``` -**Stop condition:** If the memo recommends adding `forward_only_incremental` (Case 2), pause and notify the user — they should approve the scope expansion before proceeding. If Case 1 or Case 3-with-trivial-mitigation, proceed to Task A1. +The next task fires based on outcome: Case 1 → skip to A1 (A0.5 is a no-op); Case 2 or 3 → execute A0.5 in full. + +--- + +### Task A0.5: Refactor `forward_only` for incremental state advance (fires if A0 finds Case 2 or 3) + +This task is pre-planned, not a fallback. If Task A0's memo concludes Case 1, A0.5 is a no-op (proceed to A1). If Case 2 or 3, A0.5 ships the refactor. + +**Files (filled in based on A0 memo):** +- Modify: `crates/ml-perception-trainer/src/lib.rs` (or wherever `PerceptionTrainer` lives) — add `forward_step` method that takes the prior SSM state and advances by 1 snapshot +- Modify: trunk forward kernels (`crates/ml-alpha/cuda/*.cu` for Mamba2/Cfc) — add per-event step kernel if missing +- Modify: `crates/ml-backtesting/src/harness.rs` — replace `forward_only(&window)` call with `forward_step(&snapshot, prev_state)` +- Modify: relevant test fixtures and golden-state checks + +- [ ] **Step 1: Write a failing test that asserts `forward_step` produces bit-identical output to `forward_only` of the equivalent window** + +The test should run a 100-event sequence two ways: +- Way A: call `forward_only` once on the full 100-event window +- Way B: call `forward_step` 100 times, threading state + +Assert: way A's last position == way B's final output, bit-identical (per `pearl_temporal_encoder_must_train` style golden-state check). + +Add to `crates/ml-perception-trainer/tests/incremental_forward.rs` (create file). + +```bash +SQLX_OFFLINE=true cargo test -p ml-perception-trainer --test incremental_forward 2>&1 | tail -10 +``` + +Expected: FAIL (`forward_step` doesn't exist yet). + +- [ ] **Step 2: Add persistent SSM state to `PerceptionTrainer`** + +Add fields to hold per-backtest SSM state across calls (Mamba2 state is per-layer × per-channel). Allocate matching the trunk's hidden dim. + +- [ ] **Step 3: Implement `forward_step`** + +```rust +pub fn forward_step( + &mut self, + snapshot: &Mbp10RawInput, + backtest_idx: usize, +) -> Result<[f32; N_HORIZONS]> { + // Advance the persistent SSM state by 1 event for this backtest. + // Returns the alpha probabilities for the new state. + // Uses the same kernels as forward_only but reads/writes the + // persistent state slot instead of recomputing from a window. +} +``` + +If the trunk kernels currently process a K-window in one launch, refactor to a per-step kernel (block-per-backtest, one thread sequentially advancing through the layers' SSM state). Reference Mamba2 step-wise inference patterns. + +- [ ] **Step 4: Run the test from Step 1, confirm bit-identical** + +```bash +SQLX_OFFLINE=true cargo test -p ml-perception-trainer --test incremental_forward 2>&1 | tail -10 +``` + +Expected: PASS. If not bit-identical, the per-step kernel has a state-management bug — fix before proceeding. + +- [ ] **Step 5: Update harness to use `forward_step`** + +In `crates/ml-backtesting/src/harness.rs:244`: replace `self.trainer.forward_only(&window)` with `self.trainer.forward_step(&raw, 0)` (since `n_parallel=1` in the smoke; for batched runs the per-backtest indexing applies). + +Remove the `snapshot_window` buffer if no longer needed (the SSM state IS the window memory). + +- [ ] **Step 6: Run smoke tests + existing test suite** + +```bash +SQLX_OFFLINE=true cargo test -p ml-backtesting --test stop_controller -- --ignored --nocapture 2>&1 | tail -10 +SQLX_OFFLINE=true cargo test -p ml-perception-trainer 2>&1 | tail -10 +``` + +Expected: all pass. + +- [ ] **Step 7: Commit** + +```bash +git add -A +git commit -m "$(cat <<'EOF' +arch(crt-a): forward_step incremental state advance — enables event-rate forward + +Per Task A0 cost investigation memo: forward_only was [Case 2/3] — +[reason]. Refactored PerceptionTrainer to maintain persistent SSM +state per backtest and expose forward_step(snapshot, backtest_idx) +that advances state by 1 event. Bit-identical to forward_only of the +equivalent window per the new incremental_forward golden test. + +Harness updated to call forward_step every event instead of +forward_only at stride boundaries. + +Per-event cost now O(state-dim) rather than O(K-window × state-dim). +This is the structural change Phase A's ≤ 2× wall-time target needs. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` --- @@ -221,16 +320,29 @@ EOF --- -### Task A2: Add conviction-EMA anti-hyperactivity smoothing (minimal Layer B subset) +### Task A2: Wiener-α conviction-EMA smoothing in `decision_policy` -This task ships a SMALL part of spec §4.2 to keep Phase A behaviorally sensible. Without smoothing, removing the stride gate likely produces hyperactive target oscillation as alpha jiggles event-to-event. The full multi-horizon conviction formula is Phase B — this task implements ONLY the scalar conviction EMA on whatever the controller currently uses (typically max conviction across horizons). +This task ships the Wiener-α EMA from spec §4.2 applied to scalar `max_conviction_across_horizons`. The EMA is a **load-bearing component** of continuous control — without it, event-rate trading is structurally incoherent (alpha jitters event-to-event → target oscillates → spread bleed). The full multi-horizon conviction *aggregation* (spec §4.4) is Phase B because it requires per-horizon ISV state plumbing in the kernel; the *smoothing operator itself* (Wiener-α EMA with floor) is identical regardless of what scalar conviction feeds it. **Files:** - Modify: `crates/ml-backtesting/cuda/decision_policy.cu` (`decision_policy_default` and/or `decision_policy_program`) - Modify: `crates/ml-backtesting/src/sim/mod.rs` (add `conviction_ema_d: CudaSlice` field, alloc, thread into decision launches) - Modify: `crates/ml-backtesting/tests/stop_controller.rs` (add test) -- [ ] **Step 1: Write failing test for the EMA smoothing** +- [ ] **Step 1: Audit `LobSimCuda` public API for required test helpers** + +Required helpers for the test in Step 2: +- `broadcast_alpha(&[f32; N_HORIZONS]) -> Result<()>` — already exists per harness.rs:261 +- `step_decision_with_latency(ts_ns, &sim_config) -> Result<()>` — already exists per harness.rs:262 +- `read_market_target(b: usize) -> Result<(i32, i32)>` — **must exist for this test**. If missing, add it (it's a thin wrapper over `memcpy_dtoh` on `market_targets_d`). Per [feedback_no_quickfixes](../../../../home/jgrusewski/.claude/projects/-home-jgrusewski-Work-foxhunt/memory/feedback_no_quickfixes.md): write the helper properly. + +```bash +grep -n "pub fn read_market_target\|pub fn broadcast_alpha\|pub fn step_decision_with_latency" crates/ml-backtesting/src/sim/mod.rs +``` + +For each missing helper, add it in `crates/ml-backtesting/src/sim/mod.rs` following the pattern of existing `read_*` accessors. Commit-included with this task; no separate task. Greenfields — if the API needs an accessor for testing, the accessor lives on the public API permanently. + +- [ ] **Step 2: Write failing test for the EMA smoothing** Append to `crates/ml-backtesting/tests/stop_controller.rs`: @@ -238,51 +350,51 @@ Append to `crates/ml-backtesting/tests/stop_controller.rs`: #[test] #[ignore = "requires CUDA"] fn conviction_ema_smooths_micro_oscillations() -> Result<()> { + use ml_backtesting::sim::test_helpers::minimal_batched_sim_config; let dev = match MlDevice::cuda(0) { Ok(d) => d, Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); } }; let mut sim = LobSimCuda::new(1, &dev)?; sim.upload_price_range(&[1000.0], &[20000.0])?; + let sim_config = minimal_batched_sim_config(1); // helper added in Step 1 if missing // Drive 10 alternating high/low convictions. Without EMA, target lots // would oscillate. With EMA (Wiener-α floor = 0.4), target should // damp toward the mean of the two. - let alphas_high = [0.8_f32, 0.8, 0.8, 0.8]; // strongly bullish all horizons - let alphas_low = [0.55_f32, 0.55, 0.55, 0.55]; // weakly bullish + let alphas_high: [f32; N_HORIZONS] = [0.8; N_HORIZONS]; // strongly bullish all horizons + let alphas_low: [f32; N_HORIZONS] = [0.55; N_HORIZONS]; // weakly bullish - let mut prev_target: Option = None; + let mut prev_target_signed: Option = None; let mut flips = 0; for i in 0..10 { let probs = if i % 2 == 0 { alphas_high } else { alphas_low }; sim.broadcast_alpha(&probs)?; - sim.step_decision_with_latency(1_000_000_000u64 * (i + 1) as u64, &/*sim_config*/test_sim_config())?; - let target = sim.read_market_target(0)?; - if let Some(p) = prev_target { - if (target > 0) != (p > 0) && target != 0 && p != 0 { flips += 1; } + sim.step_decision_with_latency(1_000_000_000u64 * (i + 1) as u64, &sim_config)?; + let (side, size) = sim.read_market_target(0)?; + let target_signed = if side == 0 { size } else if side == 1 { -size } else { 0 }; + if let Some(p) = prev_target_signed { + if (target_signed > 0) != (p > 0) && target_signed != 0 && p != 0 { + flips += 1; + } } - prev_target = Some(target); + prev_target_signed = Some(target_signed); } // With Wiener-α floor 0.4, EMA on alternating high/low produces a // smooth trajectory — flip count should be 0 or 1, NOT 10. assert!(flips <= 2, "conviction EMA must smooth oscillations: flips={}", flips); Ok(()) } - -fn test_sim_config() -> BatchedSimConfig { - // Minimal config for the test. Take from existing helpers if present. - todo!("use existing test config helper if available; otherwise hand-build") -} ``` -**Note:** If existing test helpers like `read_market_target` and config builders aren't present in `LobSimCuda`'s public API, **omit this unit test and rely on the cluster smoke at Task A4 as the validation gate**. Don't invent helpers — that's scope creep. +If `minimal_batched_sim_config` helper doesn't exist in a `test_helpers` module, add it in `crates/ml-backtesting/src/sim/mod.rs` under `#[cfg(test)] pub mod test_helpers { ... }` (or expose unconditionally if the existing convention does — match what the codebase already does). ```bash SQLX_OFFLINE=true cargo test -p ml-backtesting --test stop_controller conviction_ema_smooths_micro_oscillations -- --ignored --nocapture 2>&1 | tail -10 ``` -Expected (if test exists): FAIL with "conviction EMA must smooth oscillations". +Expected: FAIL with "conviction EMA must smooth oscillations". - [ ] **Step 2: Add `conviction_ema_d` device slot in `LobSimCuda`** @@ -599,63 +711,29 @@ Confirm clean state. Phase A complete. --- -### Task A5: Hyperactivity mitigation (conditional — only if Gate 1 reveals churn) - -This task fires ONLY if Step 8 detects hyperactivity (trade count balloon, e.g., > 2× baseline, despite Sharpe staying within ±15%). - -**Files:** -- Modify: `crates/ml-backtesting/cuda/decision_policy.cu` (tighten EMA α floor OR add hysteresis on target deltas) - -- [ ] **Step 1: Quantify hyperactivity** - -Pull the trade CSV. Compare: -- Mean trade hold time (should be similar to baseline — small or matches max_hold setting) -- Trade count distribution (per-hour, per-day) -- Fees paid (proportional to round-trip count) - -- [ ] **Step 2: Choose mitigation** - -Two options: - -**Option A** (preferred if minor): raise the Wiener-α floor from 0.4 to 0.6 in §4.2's `alpha_active = fmaxf(alpha_raw, 0.6f);`. Damps further. Lower responsiveness. - -**Option B** (stronger): add target-delta hysteresis in `seed_inflight_limits_batched`. Don't seed a new order unless `|new_target − effective_position| ≥ delta_floor` where delta_floor is e.g. 0.3 lots (rounded). Skips micro-rebalances. - -- [ ] **Step 3: Apply mitigation, re-test, re-validate Gate 1** - -Follow A4 steps with the chosen mitigation. If Gate 1 passes after mitigation, document the chosen value as a tuned constant in the spec amendment (acknowledge as tuned, not derived — pearl_adaptive_not_tuned says "fixes are signal-driven not tuned constants" so any tuned floor should be a temporary stopgap with a follow-up to make it ISV-derived). - -- [ ] **Step 4: Commit** - -If mitigation chosen: -```bash -git add -A -git commit -m "fix(crt-a): tighten conviction-EMA floor 0.4→0.6 — mitigates hyperactivity seen in [smoke]" -``` - -If mitigation NOT needed (hyperactivity wasn't a real problem), no commit — leave task A5 as documentation that it was considered and ruled out. - ---- - ## Notes for the Implementer -- **TDD discipline.** Each task starts with a failing test where possible (Tasks A2, A5 explicitly do); Tasks A0 and A1 are refactor/research and don't fit the failing-test mold. Per [feedback_no_quickfixes](../../../../home/jgrusewski/.claude/projects/-home-jgrusewski-Work-foxhunt/memory/feedback_no_quickfixes.md) — every issue gets a proper fix; tests where a test makes sense. +- **TDD discipline.** Tasks A0.5 and A2 start with failing tests. A0 is read-only research. A1 is refactor (existing tests stay green). A3/A4 are verification. - **Each task is one commit.** No mid-task commits. - **Greenfields atomic refactor (Task A1).** Every `decision_stride` consumer migrates in one commit per [feedback_no_partial_refactor](../../../../home/jgrusewski/.claude/projects/-home-jgrusewski-Work-foxhunt/memory/feedback_no_partial_refactor.md). Don't leave a "compat" branch around. +- **No fallbacks.** Greenfields means we resolve properly: + - Missing test helpers → add them to the public API (Task A2 Step 1). + - Forward pass is stateless → refactor it to be stateful (Task A0.5). + - Kernel changes needed → make them. Spec §3.3 already accepts kernel-signature changes. + - **Pearl conformance:** - Task A2 uses [pearl_wiener_optimal_adaptive_alpha](../../../../home/jgrusewski/.claude/projects/-home-jgrusewski-Work-foxhunt/memory/pearl_wiener_optimal_adaptive_alpha.md) and [pearl_wiener_alpha_floor_for_nonstationary](../../../../home/jgrusewski/.claude/projects/-home-jgrusewski-Work-foxhunt/memory/pearl_wiener_alpha_floor_for_nonstationary.md). - Task A2 uses [pearl_first_observation_bootstrap](../../../../home/jgrusewski/.claude/projects/-home-jgrusewski-Work-foxhunt/memory/pearl_first_observation_bootstrap.md) for EMA init. - Task A1 follows [feedback_no_legacy_aliases](../../../../home/jgrusewski/.claude/projects/-home-jgrusewski-Work-foxhunt/memory/feedback_no_legacy_aliases.md) — no compat shim. - Task A4 follows [feedback_push_before_deploy](../../../../home/jgrusewski/.claude/projects/-home-jgrusewski-Work-foxhunt/memory/feedback_push_before_deploy.md). + - Task A0.5 reference golden-state pattern from [pearl_temporal_encoder_must_train](../../../../home/jgrusewski/.claude/projects/-home-jgrusewski-Work-foxhunt/memory/pearl_temporal_encoder_must_train.md) for bit-identical incremental-vs-window test. -- **Local GPU is RTX 3050 Ti 4GB** ([user_dev_environment](../../../../home/jgrusewski/.claude/projects/-home-jgrusewski-Work-foxhunt/memory/user_dev_environment.md)) — may not be able to run the full smoke locally; cluster smoke at Task A4 is the real validation. +- **Local GPU is RTX 3050 Ti 4GB** ([user_dev_environment](../../../../home/jgrusewski/.claude/projects/-home-jgrusewski-Work-foxhunt/memory/user_dev_environment.md)) — may not be able to run the full smoke locally; cluster smoke at Task A4 is the real validation. This is a hardware constraint, not a fallback. -- **If Task A0 returns Case 2 (`forward_only` is stateless K-window per call):** STOP and notify the user. This expands Phase A scope significantly (need `forward_only_incremental` first). Don't proceed without explicit approval. - -- **If Task A4 Gate 1 fails:** apply [feedback_stop_on_anomaly](../../../../home/jgrusewski/.claude/projects/-home-jgrusewski-Work-foxhunt/memory/feedback_stop_on_anomaly.md) — terminate, diagnose, fix, re-run. Use SP20/SP21-style per-event instrumentation if needed. Do NOT advance to Phase B with a red Gate 1. +- **If Task A4 Gate 1 fails:** apply [feedback_stop_on_anomaly](../../../../home/jgrusewski/.claude/projects/-home-jgrusewski-Work-foxhunt/memory/feedback_stop_on_anomaly.md) — terminate, diagnose, fix root cause, re-run. Use SP20/SP21-style per-event instrumentation if needed. Do NOT advance to Phase B with a red Gate 1. Root-cause investigation is not a fallback — it's the correct response to a failed validation. ---