docs(sp13): v3 spec + plan — Hold-pricing replaces Hold-elimination

P0a.T3 v2 implementer's audit revealed `DirectionAction` enum doesn't exist; the
codebase uses an 8-variant fused `ExposureLevel` (ShortSmall/Half/Full, Hold,
LongSmall/Half/Full, Flat) with cross-crate consumers across 77 files and 32+
test files pinning the 8-variant invariant. Atomic Hold elimination would
cascade massively.

User insight (2026-05-04): Hold being FREE is the bug, not Hold itself. MFT
trading legitimately needs multi-bar holds; we want the model to use them
deliberately, not as a CQL-bias lazy default. Holding isn't free in the real
world — broker fees, margin interest, opportunity cost.

v3 reframes as Hold-pricing:
- 4-way action space stays; ExposureLevel::Hold stays; no cross-crate cascade
- 3 new ISV slots (380-382): HOLD_COST_INDEX, HOLD_RATE_TARGET_INDEX,
  HOLD_RATE_OBSERVED_EMA_INDEX
- Hold-rate observer: small GPU kernel + Pearls A+D smoothing
- Hold-cost controller: 5-line deficit-driven formula
  (excess > target → cost rises 1×→5× base; observed ≤ target → relax)
- Per-bar reward subtraction at action == DIR_HOLD site
- 2 GPU oracle tests for the controller

P0a.T3 cuts from ~250 LOC + 32-test cascade → ~120 LOC additive. T1+T2
already-staged work unchanged. T4/T5/Layer B/C/D structure preserved.

Tension with pearl_event_driven_reward_density_alignment acknowledged in
spec — per-bar Hold cost is exposure-NEGATIVE (away from Hold), models real
economic carry, ISV-bounded by controller. Inverse of the pearl's failure
mode. Faithful reward modeling, not artificial shaping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-05-04 23:18:58 +02:00
parent 0ca45ef61d
commit ba83fcd1f5
2 changed files with 299 additions and 185 deletions

View File

@@ -124,9 +124,11 @@
---
## Phase 0a — Hold elimination + dir_acc instrumentation (1 atomic commit)
## Phase 0a — Hold-pricing + dir_acc instrumentation (1 atomic commit, additive)
**Per `feedback_no_partial_refactor`**: every consumer of the 4-way direction action space migrates to 3-way in a single commit. Replay buffer compatibility intentionally broken (greenfield).
> **v3 NOTE**: P0a.T3 was v2's atomic Hold elimination (would have cascaded across 77 files / 200+ sites including 32 test files). After the T3 implementer's audit found `DirectionAction` enum doesn't exist (codebase uses 8-variant fused `ExposureLevel`), v3 reframed as **Hold-PRICING**: ~120 LOC, additive, no contract change, no cross-crate cascade. The File Structure table above was authored for v2 — entries that conflict with v3 (line 5159 direction-bias re-derivation, financials.rs/monitoring.rs action-packing 12→9, val_dir_dist 4→3, SP5 Pearl 8 trail-dist migration, 30+ stale-doc cleanup) are **NO LONGER IN SCOPE**. The authoritative T3 spec is the v3 P0a.T3 section below.
**P0a is additive, atomic per `feedback_no_partial_refactor`** — but "atomic" here means single commit, NOT contract change. Replay buffer / fxcache compatibility preserved.
### Task P0a.T1: ISV slot constants + state-reset registry
@@ -356,163 +358,217 @@ fn dir_acc_empty_batch_returns_sentinel() {
---
### Task P0a.T3: Direction action space refactor — atomic Hold removal
### Task P0a.T3 (v3): Hold-pricing — adaptive cost controller (additive, no contract change)
**Goal:** `NUM_DIRECTIONS: 4 → 3`. Eliminate Hold from `DirectionAction` enum. Update all 7+ consumers in lockstep. Per `feedback_no_partial_refactor` — single commit, no partial migration.
**Goal:** Make Hold action cost something so the policy uses it deliberately. v2 was atomic Hold elimination; v3 prices Hold via ISV-driven adaptive controller targeting a Hold-rate (~20% — MFT-ish). Per `feedback_isv_for_adaptive_bounds` — base cost is the only constant; rate target and observed rate are ISV slots; cost output is ISV-driven.
**Action semantics in 3-way world** (replaces `project_hold_action_design`):
**Architecture:**
- 3 new ISV slots (380-382): `HOLD_COST_INDEX`, `HOLD_RATE_TARGET_INDEX`, `HOLD_RATE_OBSERVED_EMA_INDEX`
- Hold-rate observer: small GPU kernel writing `(action == DIR_HOLD ? 1.0 : 0.0)` to scratch per-bar; Pearls A+D smoothing (α=0.05) into ISV[382]
- Hold-cost controller: 5-line formula in `training_loop.rs` reading observed rate and writing cost to ISV[380]
- Reward composition: per-bar `reward -= ISV[HOLD_COST_INDEX]` when action == Hold at the existing reward composition site in `experience_kernels.cu`
- 2 GPU oracle tests for the controller
| Prior position | Action picked | Result |
|---|---|---|
| Flat | Long | Open Long |
| Long | Long | Keep Long (was old Hold-Long) |
| Short | Long | Flip to Long |
| Long | Short | Flip to Short |
| Long | Flat | Close position |
| Flat | Flat | No-op |
**Per `pearl_event_driven_reward_density_alignment` tension** (acknowledged): per-bar Hold cost is per-bar reward shaping, which the pearl warns against. The cost is justified as: (a) economically realistic (broker fees, margin interest, opportunity cost), (b) ISV-bounded by the controller, (c) inverse-direction from the pearl's failure mode (pulls policy AWAY from Hold-default, doesn't create exposure-positive bias).
`Long` is "have Long position", not "open Long". Same for Short. Flat = "no position".
**Files:**
- Modify: `crates/ml/src/cuda_pipeline/sp13_isv_slots.rs` (add 3 constants + defaults)
- Modify: `crates/ml/src/cuda_pipeline/sp5_isv_slots.rs` (`SP5_SLOT_END = 383`; layout fingerprint extension)
- Modify: `crates/ml/src/cuda_pipeline/state_layout.cuh` (3 new SP13 slot defines + `ISV_TOTAL_DIM = 383`)
- Modify: `crates/ml/src/trainers/dqn/state_reset_registry.rs` (1 new entry — slot 382 per-fold, sentinel 0.0)
- Modify: `crates/ml/src/trainers/dqn/trainer/training_loop.rs` (reset arm for slot 382 + per-step controller formula)
- Modify: `crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs` (constructor static-init for slots 380, 381; bump `ISV_TOTAL_DIM = 383`; fingerprint extension)
- Create: `crates/ml/src/cuda_pipeline/hold_rate_observer_kernel.cu` (~30 LOC GPU kernel)
- Modify: `crates/ml/build.rs` (cubin registration)
- Modify: `crates/ml/src/cuda_pipeline/experience_kernels.cu` (locate reward composition site near SP12 alpha cap; add per-bar Hold cost subtraction when action == DIR_HOLD)
- Modify: `crates/ml/tests/sp13_phase0_oracle_tests.rs` (add 2 controller tests)
**Encoding (CRITICAL — preserves codebase Short-first canonical anchor):**
```
OLD: Short=0, Hold=1, Long=2, Flat=3 (4-way)
NEW: Short=0, Long=1, Flat=2 (3-way) — Hold removed; Long & Flat shift down
```
This is NOT `Long=0, Short=1, Flat=2` — keeping Short=0 avoids 30+ stale-comment churn across kernel headers and tests.
- [ ] **Step 1:** Update `state_layout.cuh`:
```c
// SP13: direction action space reduced 4 → 3. Hold removed; Long shifts 2→1, Flat 3→2.
// Short-first ordering preserved per codebase canonical anchor.
#define NUM_DIRECTIONS 3
#define DIRECTION_SHORT 0
#define DIRECTION_LONG 1
#define DIRECTION_FLAT 2
// (DIRECTION_HOLD definition deleted — was =1 in 4-way scheme)
```
- [ ] **Step 2:** Update canonical Rust enum at **`crates/ml-core/src/common/action.rs:124-132`** (NOT `crates/ml/src/agents/policy.rs` — that path was speculative; Explore agent confirmed the canonical site):
- [ ] **Step 1:** Add 3 ISV slot constants + defaults to `sp13_isv_slots.rs`:
```rust
#[repr(u8)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DirectionAction {
Short = 0,
Long = 1,
Flat = 2,
}
pub const HOLD_COST_INDEX: usize = 380;
pub const HOLD_RATE_TARGET_INDEX: usize = 381;
pub const HOLD_RATE_OBSERVED_EMA_INDEX: usize = 382;
/// Per-bar Hold cost base (controller can scale up to 5×, down to 0.5×).
/// Small per-bar (~10 ticks of price for ES.FUT at ~0.25 tick value) but cumulative
/// across long Hold runs becomes meaningful relative to capped trade reward (±5..10).
pub const HOLD_COST_BASE: f32 = 0.001;
pub const HOLD_RATE_TARGET_DEFAULT: f32 = 0.20;
pub const HOLD_COST_CONTROLLER_GAIN: f32 = 5.0;
pub const HOLD_COST_FLOOR_RATIO: f32 = 0.5; /* never below 0.5 × base */
pub const HOLD_COST_CEIL_RATIO: f32 = 5.0; /* never above 5.0 × base */
```
And `crates/ml-core/src/state_layout.rs:48-49`:
- [ ] **Step 2:** Bump `SP5_SLOT_END = 383` (was 380 from T1) in `sp5_isv_slots.rs`. Extend `SP5_LAYOUT_FINGERPRINT_FRAGMENT` with the 3 new slot names. Update test expectations: `SP5_PRODUCER_COUNT = 209`.
```rust
// (3 actions: Short / Long / Flat; Hold removed in SP13)
pub const NUM_DIRECTIONS: usize = 3; // was 4
```
- [ ] **Step 3:** Mirror in `state_layout.cuh`: `#define ISV_TOTAL_DIM 383`; add the 3 new SP13 slot defines.
Compiler will surface every `Hold` match arm. Fix each — do NOT add `_ => unreachable!()` cover-ups.
- [ ] **Step 4:** Add reset registry entry in `state_reset_registry.rs` for slot 382 (per-fold, sentinel `0.0` — no observations at fold start). Slots 380 and 381 are static-init in constructor.
- [ ] **Step 3:** Update `experience_action_select` direction branch at `experience_kernels.cu:1013`: argmax over 3 instead of 4. Thompson sampling on 3 atoms (`pearl_thompson_for_distributional_action_selection`).
- [ ] **Step 5:** Add reset_named_state dispatch arm for slot 382 in `training_loop.rs` (writes 0.0).
- [ ] **Step 4:** **NEW — Direction-bias signal at `experience_kernels.cu:5159`:**
- [ ] **Step 6:** Constructor static-init in `gpu_dqn_trainer.rs` for slots 380 (`HOLD_COST_BASE`) and 381 (`HOLD_RATE_TARGET_DEFAULT`). Extend `layout_fingerprint_seed()` byte-string with 3 new slot names + `ISV_TOTAL_DIM=383`. Bump `ISV_TOTAL_DIM` constant.
The 4-tuple per-direction bias weights `[Short=1.0, Hold=0.5, Long=1.0, Flat=0.0]` encoded a softening gate where Hold attenuated the Q-signal. With Hold removed:
- [ ] **Step 7:** Write the Hold-rate observer kernel `crates/ml/src/cuda_pipeline/hold_rate_observer_kernel.cu`:
```cuda
// SP13: 4-tuple direction-bias weights → 3-tuple (Hold's 0.5 softening gate removed
// since Hold itself is removed; Long and Short stay full-weight, Flat stays soft).
// OLD: const float dir_bias[4] = {1.0f, 0.5f, 1.0f, 0.0f}; // [Short, Hold, Long, Flat]
const float dir_bias[3] = {1.0f, 1.0f, 0.0f}; // [Short, Long, Flat]
// hold_rate_observer_kernel.cu
// Per-step: writes 1.0 if any bar in batch picked Hold, 0.0 otherwise (or aggregated
// fraction). Single-block reduce; mapped-pinned output for Pearls A+D smoothing.
#include "state_layout.cuh"
extern "C" __global__ void hold_rate_observer_kernel(
const int* __restrict__ actions_dir, /* [B] direction actions, values ∈ {0..3} */
int batch_size,
float* __restrict__ hold_rate_out /* [1] = (count(Hold) / B) */
) {
const int tid = threadIdx.x;
const int bdim = blockDim.x;
extern __shared__ int sh_hold[];
int local_hold = 0;
for (int i = tid; i < batch_size; i += bdim) {
if (actions_dir[i] == DIR_HOLD) local_hold += 1;
}
sh_hold[tid] = local_hold;
__syncthreads();
for (int s = bdim / 2; s > 0; s >>= 1) {
if (tid < s) sh_hold[tid] += sh_hold[tid + s];
__syncthreads();
}
if (tid == 0) {
hold_rate_out[0] = (batch_size > 0) ? ((float)sh_hold[0] / (float)batch_size) : 0.0f;
}
}
```
Verify the loop bounds at `:5159` use `NUM_DIRECTIONS` (now 3). If hardcoded `<4`, change.
(Single-block, 256 threads, 1 KB shared mem. Mirror the launch infrastructure of `aux_dir_acc_reduce` from T2.)
- [ ] **Step 5:** Update Q-head output dim in `dqn_inference.cu` and `dqn_param_layout.rs`: 4 → 3. Bump layout fingerprint seed (invalidates cached checkpoints — greenfield acceptable).
- [ ] **Step 8:** Register cubin in `build.rs` matching the existing pattern.
- [ ] **Step 6:** Update C51/IQN per-direction-bucket loops at the specific sites Explore agent located:
- `c51_loss_kernel.cu:394` — doc comment update
- `c51_loss_kernel.cu:460``for (int k=1; k<4; k++)``< NUM_DIRECTIONS`
- `c51_loss_kernel.cu:1088` — argmax across direction bins → 3
- `c51_grad_kernel.cu`**AUDIT**: grep for `for.*<.*4` direction-axis loops, update each
- `experience_kernels.cu:4059` — direction accumulation loop
- `experience_kernels.cu:5306` — conviction EMA loop
- `experience_kernels.cu:6371-6397` — 4× per-atom direction loops, all need bound change
Per-direction ISV slot ranges (SP4/SP5/SP6 atoms_pos/tau/...) KEEP 4-entry length; slot index 3 dead-space:
```rust
// SP13: NUM_DIRECTIONS reduced 4→3 (Hold removed). Slot index 3 dead-space until
// SP13 close-out recompaction; consumers read by direction index ∈ 0..2 so dead
// slot is never touched.
```
- [ ] **Step 7:** SP5 Pearl 8 trail-distance migration (per-direction ISV slot range stays 4 entries; producer kernel rewrites for new direction indexing):
- `sp5_isv_slots.rs:272` — `TRAIL_DIST_PER_DIR_BASE=270..274` doc-comment update from `[4 dirs: Short, Hold, Long, Flat]` to `[3 dirs at Short=0, Long=1, Flat=2; slot 273 dead-space]`
- `pearl_8_trail_kernel.cu` — producer writes at new indices: Short→slot 270, Long→slot 271 (was Hold), Flat→slot 272 (was Long); slot 273 stays zero
- `state_reset_registry.rs:974` — comment update; reset values stay 4-entry (slot 273 stays sentinel)
- `training_loop.rs:4046-4048` — Pearl 8 `apply_pearls_ad_kernel` calls: 4 → 3 (skip slot 273 — no smoothing of dead slot)
- [ ] **Step 8:** **Action packing recompacts 12 → 9 slots** (real shape change, not slot-3 dead-space):
- `crates/ml/src/trainers/dqn/financials.rs:27,197` — `exp_idx = dir*3 + mag` semantics: was 4 dirs × 3 mags = 12 indices `[Short[0..3], Hold[3..6], Long[6..9], Flat[9..12]]`; now 3 dirs × 3 mags = 9 indices `[Short[0..3], Long[3..6], Flat[6..9]]`
- `crates/ml/src/trainers/dqn/monitoring.rs:341` — `action_counts[9..12]` Flat-mag indexing: now `action_counts[6..9]`
- `crates/ml/src/cuda_pipeline/iqn_dual_head_kernel.cu:200,245,1199` — action layout `[direction, magnitude, order, urgency]` is 4 SUB-ACTIONS per sample (NOT 4-way direction); only the direction VALUE range shrinks 0..3 → 0..2; `actions[sample * 4 + 0]` indexing stays. Verify consumers expect dir < 3.
- Action-counts buffer allocation: shrink from `12 * batch_size` → `9 * batch_size` (search for allocation site)
- [ ] **Step 9:** Update HEALTH_DIAG 4-bucket → 3-bucket emit at all sites:
- `metrics.rs:949` — `"val_dir_dist [short={:.4} hold={:.4} long={:.4} flat={:.4}]"` → `"val_dir_dist [short={:.4} long={:.4} flat={:.4}]"`
- `magnitude_distribution.rs:88,217` — `EVAL_DIR_DIST` 4-tuple → 3-tuple
- `distributional_q_tests.rs:790` — `σ_C51 per direction` test output
- [ ] **Step 10:** Update KL divergence calc if present. Explore agent did NOT find a `kl_divergence_kernel.cu` — verify with `find crates -name "*kl*" -type f`. If absent, this step is a no-op (record in commit message).
- [ ] **Step 11:** Update tests + build comments:
- `crates/ml/src/trainers/dqn/trainer/tests.rs:542` — `assert_eq!(config.num_actions, 4, …)` → `3`
- `crates/ml/src/trainers/dqn/distributional_q_tests.rs:888` — `const PROD_B0: i32 = 4` → `3`
- `crates/ml/src/cuda_pipeline/thompson_test_kernel.cu` — direction count constant 4 → 3
- `crates/ml/build.rs:490,496` — comment `"4-thread single-block kernel (one per direction: Short=0, Hold=1, Long=2, Flat=3)"` → `"3-thread … Short=0, Long=1, Flat=2"`
- [ ] **Step 12:** Add 5 GPU oracle tests for 3-direction action selection (NEW Short-first indexing) in `sp13_phase0_oracle_tests.rs`:
- [ ] **Step 9:** Add launcher in `gpu_dqn_trainer.rs` (mirroring `launch_aux_dir_acc_reduce`):
```rust
// New encoding: Short=0, Long=1, Flat=2
#[test] fn action_select_3way_argmax_short() { /* q=[2.0, 1.0, 0.5] → 0 (Short wins) */ }
#[test] fn action_select_3way_argmax_long() { /* q=[1.0, 2.0, 0.5] → 1 (Long wins) */ }
#[test] fn action_select_3way_argmax_flat() { /* q=[0.5, 0.3, 2.0] → 2 (Flat wins) */ }
#[test] fn action_select_no_hold_leaks() {
// 100 random q-vectors → 0 ≤ action < 3 always; never 3
}
#[test] fn action_packing_9_slot_invariant() {
// Pack (dir, mag) for all 9 (dir, mag) combinations; verify exp_idx ∈ [0, 9)
pub fn launch_hold_rate_observer(
&self,
actions_dir: &CudaSlice<i32>,
batch_size: i32,
out_1: &mut CudaSlice<f32>,
) -> Result<(), MLError> {
let bdim: u32 = 256;
let cfg = LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (bdim, 1, 1),
shared_mem_bytes: bdim * std::mem::size_of::<i32>() as u32,
};
unsafe {
self.hold_rate_observer
.clone()
.launch(cfg, (actions_dir, batch_size, out_1))?;
}
Ok(())
}
```
- [ ] **Step 13:** Stale doc cleanup — implementer audit:
Add `hold_rate_observer: CudaFunction` and `hold_rate_buf: MappedF32Buffer` (1 float).
```bash
grep -rn "Short=0.*Hold=1.*Long=2.*Flat=3\|S=Short.*H=Hold.*L=Long.*F=Flat\|\[Short, Hold, Long, Flat\]" \
crates/ --include="*.rs" --include="*.cu" --include="*.cuh" \
| grep -v "// SP13:"
- [ ] **Step 10:** Wire the per-step launch + Pearls A+D smoothing + controller in `training_loop.rs` (after action selection, before next step):
```rust
// SP13 v3: Hold-rate observer + Hold-cost controller
self.launch_hold_rate_observer(
&actions_dir, /* [B] direction actions from policy this step */
batch_size as i32,
self.hold_rate_buf.device_slice_mut(),
)?;
// Pearls A+D smoothing (α=0.05) into ISV[HOLD_RATE_OBSERVED_EMA_INDEX]
self.launch_apply_pearls_ad(
self.hold_rate_buf.device_slice(),
1,
HOLD_RATE_OBSERVED_EMA_INDEX,
/* alpha */ 0.05,
)?;
// Controller (deficit-driven): when observed > target, raise cost
let observed = self.read_isv_slot(HOLD_RATE_OBSERVED_EMA_INDEX);
let target = self.read_isv_slot(HOLD_RATE_TARGET_INDEX);
let excess = (observed - target).max(0.0);
let hold_cost = HOLD_COST_BASE * (1.0 + HOLD_COST_CONTROLLER_GAIN * excess);
let hold_cost = hold_cost.clamp(
HOLD_COST_BASE * HOLD_COST_FLOOR_RATIO,
HOLD_COST_BASE * HOLD_COST_CEIL_RATIO,
);
self.write_isv_slot(HOLD_COST_INDEX, hold_cost)?;
```
Update narrative comments to `Short=0, Long=1, Flat=2` ordering. Specific known sites:
- `crates/ml/src/trainers/dqn/monitoring.rs:13`
- `crates/ml/src/trainers/dqn/trainer/mod.rs:1966`
- 20+ kernel header comments
(Match existing patterns for ISV reads/writes in this file. The `apply_pearls_ad_kernel` API may differ slightly; consult adjacent SP11/SP12 launches for the exact signature.)
- [ ] **Step 14:** Sanity-check no Hold leaks:
- [ ] **Step 11:** Add per-bar Hold cost subtraction in `experience_kernels.cu`. Locate the reward composition site (SP12 asymmetric cap is around line 2788 per `pearl_symmetric_clamp_audit`):
```bash
grep -rn "DIRECTION_HOLD\|DirectionAction::Hold\|::Hold\b\|hold=\|action == 3\|< 4.*direction\|dir.*== 1.*Hold" \
crates/ --include="*.rs" --include="*.cu" --include="*.cuh" \
| grep -v test | grep -v "// SP13"
```cuda
// SP13 v3: per-bar Hold cost (priced action — ISV-driven adaptive)
if (action_dir == DIR_HOLD) {
base_reward -= isv[HOLD_COST_INDEX];
}
// (then existing SP12 asymmetric cap, etc.)
float r = fmaxf(-10.0f, fminf(base_reward, 5.0f));
```
Expected: zero hits.
(Adapt to actual variable names at the call site. Hold cost subtraction goes BEFORE the SP12 asymmetric cap so the cost participates in the bounded clamp — keeps total reward in the same range.)
- [ ] **Step 15:** Stage all P0a.T3 changes (atomic with T1, T2, T4 in P0a.T5 commit).
- [ ] **Step 12:** Add 2 GPU oracle tests to `sp13_phase0_oracle_tests.rs`:
```rust
#[test]
fn hold_rate_observer_all_hold_returns_one() {
// Batch where every action == DIR_HOLD (=1) → out = 1.0
let actions = vec![1_i32; 10];
let out = run_hold_rate_observer(&actions);
assert!((out - 1.0).abs() < 1e-6);
}
#[test]
fn hold_rate_observer_no_hold_returns_zero() {
// Batch with mix of Short(0)/Long(2)/Flat(3), no Hold(1) → out = 0.0
let actions = vec![0_i32, 2, 3, 0, 2, 3];
let out = run_hold_rate_observer(&actions);
assert!(out.abs() < 1e-6);
}
// (Optional 3rd: half-hold case verifying out = 0.5 — implementer adds if scaffold permits)
```
- [ ] **Step 13:** Verify on RTX 3050 Ti:
```bash
SQLX_OFFLINE=true cargo check -p ml --lib # clean
SQLX_OFFLINE=true cargo build -p ml --release # cubin compiles
SQLX_OFFLINE=true cargo test -p ml --features cuda --test sp13_phase0_oracle_tests -- --ignored
# Expected: 8 passing (6 from T2 + 2 new)
SQLX_OFFLINE=true cargo test -p ml --features cuda --test sp12_reward_math_tests -- --ignored # SP12 still green
```
- [ ] **Step 14:** Stage all P0a.T3 v3 changes (atomic with T1, T2, T4 in P0a.T5 commit):
```bash
git add crates/ml/src/cuda_pipeline/sp13_isv_slots.rs \
crates/ml/src/cuda_pipeline/sp5_isv_slots.rs \
crates/ml/src/cuda_pipeline/state_layout.cuh \
crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs \
crates/ml/src/cuda_pipeline/hold_rate_observer_kernel.cu \
crates/ml/src/cuda_pipeline/experience_kernels.cu \
crates/ml/src/trainers/dqn/state_reset_registry.rs \
crates/ml/src/trainers/dqn/trainer/training_loop.rs \
crates/ml/build.rs \
crates/ml/tests/sp13_phase0_oracle_tests.rs
```
---

View File

@@ -1,6 +1,30 @@
# SP13 — Redefine Success for Predictive Skill (v2)
# SP13 — Redefine Success for Predictive Skill (v3)
**Date**: 2026-05-04 (v2 — supersedes v1 in-place)
**Date**: 2026-05-04 (v3 — supersedes v2 in-place after architectural finding)
## v2 → v3 change (added during P0a.T3 dispatch)
P0a.T3 implementer's audit revealed `DirectionAction` enum doesn't exist — the action
space uses an 8-variant `ExposureLevel` (fused dir+mag) with cross-crate consumers
across 77 files / 200+ Hold sites. Eliminating Hold would cascade through 32+ test
files pinning the 8-variant invariant.
User insight (2026-05-04): "Why is Hold free anyway? MFT-style with Hold for when it's
really needed could be fine. Holding isn't free — infrastructure costs still money even
doing nothing. The model should learn to hold carefully."
v3 reframes: **Hold being FREE is the bug, not Hold itself**. Pricing Hold:
- Preserves legitimate MFT use cases (multi-bar conviction holds)
- Avoids 200+ site cross-crate cascade
- Tests a sharper hypothesis: "free Hold + CQL bias = lazy default; priced Hold forces deliberate use"
- Cuts P0a.T3 from ~250 LOC + 32-test cascade → ~120 LOC, no cross-crate refactor
- Replay buffer / fxcache compatibility preserved (no contract change)
- 4-way action space stays; HEALTH_DIAG val_dir_dist stays 4-bucket; no stale-doc
cleanup needed
The data-investigation hypothesis ("data has signal, Hold hides it") is unchanged. The
test mechanism shifts from "remove Hold so policy must commit" to "price Hold so policy
must commit when no edge exists, can still hold deliberately when edge persists".
**Branch**: continues `sp11-reward-as-controlled-subsystem` (single coherent architectural lineage)
**Builds on**: SP11 (cap stability) + SP12 (event-driven density) + asymmetric loss aversion
**Anchor pearls**:
@@ -78,48 +102,59 @@ persists because Hold hides it. P0a smoke decides cleanly:
## The five changes
### Change 1: Eliminate Hold action
### Change 1: Price Hold (replaces v2's "Eliminate Hold")
**Current**: 4-way direction `{Short=0, Hold=1, Long=2, Flat=3}` × magnitude × ord × urg.
**New**: 3-way `{Short=0, Long=1, Flat=2}` — Hold removed, Short stays at 0 (preserves
canonical anchor used across 30+ kernel comments and tests), Long shifts down 2 → 1,
Flat shifts down 3 → 2.
Hold has zero cost: model can pick it indefinitely with no penalty. Combined with CQL's
conservative Q-bias (pulls toward minimum-variance action), Hold becomes the safe
default ~45% of the time across SP1-SP12.
**Action semantics in 3-way world** (replaces `project_hold_action_design`):
- `Long` at t when prior position = Flat → open Long
- `Long` at t when prior position = Long → keep Long (same as old "Hold-Long")
- `Long` at t when prior position = Short → flip to Long
- `Flat` at t when any position → close all positions
- `Short` symmetric
**New**: same 4-way action space; **Hold action incurs a per-bar cost**. Adaptive,
ISV-driven controller targets a Hold-rate (~20% — MFT-ish, deliberate use). Cost
self-balances:
`Long` is no longer "open Long" specifically; it is "have Long position". `Long` is a
state, not a verb.
```
At each bar, if action == Hold:
reward[bar] -= ISV[HOLD_COST_INDEX]
**Affected** (atomic per `feedback_no_partial_refactor`, ~5070 touch-sites mapped by
Explore agent 2026-05-04):
- Canonical Rust enum at `crates/ml-core/src/common/action.rs:124-132` and
`NUM_DIRECTIONS: 4 → 3` at `crates/ml-core/src/state_layout.rs:48-49`
- `state_layout.cuh` C/CUDA mirror: `#define NUM_DIRECTIONS 3` and `DIRECTION_SHORT=0,
DIRECTION_LONG=1, DIRECTION_FLAT=2` (DIRECTION_HOLD removed)
- Q-head output dim 4 → 3, weight/bias re-init, layout fingerprint bump
- Action selection kernel argmax over 3 (`experience_action_select`)
- C51/IQN per-direction-bucket loops (~6 `for d<4` sites become `< NUM_DIRECTIONS`)
- **Action packing recompacts 12 → 9 slots**: `action_counts` buffer and `exp_idx =
dir*3 + mag` packing in `financials.rs:197` shrink from 12 → 9 (3 dirs × 3 mags).
Real shape change, not slot-3 dead-space. Consumers in `monitoring.rs:341` reindex.
- **Direction-bias signal at `experience_kernels.cu:5159`**: 4-tuple per-direction
bias weights `[Short=1.0, Hold=0.5, Long=1.0, Flat=0.0]` re-derives to 3-tuple
`[Short=1.0, Long=1.0, Flat=0.0]`. Hold's 0.5 was a softening gate; with Hold gone
the gate is no longer needed (Long/Short are full-weight, Flat is the soft branch).
- SP5 Pearl 8 per-direction trail-distance ISV range `TRAIL_DIST_PER_DIR_BASE=270..274`:
producer rewrites at new indices `{0=Short, 1=Long, 2=Flat}`; slot 273 dead-space
(per-direction-indexed ISV ranges keep length 4 to avoid cascade-renumber of all
downstream slots — consumers read by direction index ∈ 0..2 so dead slot is never
touched). Recompaction deferred to SP13 close-out.
- HEALTH_DIAG val_dir_dist 3-bucket emit: `metrics.rs:949`, `magnitude_distribution.rs:88,217`,
`distributional_q_tests.rs:790`
- KL divergence calc (if present)
- 30+ stale doc/comment lines across kernel headers — implementer audits in same commit
Controller (per-step, after action selection):
observed_hold_rate = ISV[HOLD_RATE_OBSERVED_EMA_INDEX] /* fast EMA of Hold-pick frequency */
target_hold_rate = ISV[HOLD_RATE_TARGET_INDEX] /* default 0.20 */
excess = max(0, observed_hold_rate - target_hold_rate)
hold_cost = HOLD_COST_BASE × (1 + 5 × excess)
hold_cost = clamp(hold_cost, HOLD_COST_BASE × 0.5, HOLD_COST_BASE × 5.0)
ISV[HOLD_COST_INDEX] = hold_cost
```
When the model abuses Hold (rate > target), cost rises until Hold becomes uneconomic.
When Hold rate ≤ target, cost relaxes to baseline (just realistic carry). The model
learns to use Hold deliberately for high-conviction multi-bar positions.
**Action semantics preserved**:
- `Hold` (action 1) — keep current position; pays per-bar cost; legitimate for MFT
- `Flat` (action 3) — close to no position; pays close-slippage once, then near-zero infra
- `Long`/`Short` — open/keep directional position; pays carry implicitly via P&L
**Affected** (~120 LOC, additive, no contract change):
- 3 new ISV slots: `HOLD_COST_INDEX`, `HOLD_RATE_TARGET_INDEX`, `HOLD_RATE_OBSERVED_EMA_INDEX`
- Hold-rate observer: small GPU kernel writing `(action == Hold ? 1.0 : 0.0)` to scratch,
Pearls A+D smoothing into ISV[HOLD_RATE_OBSERVED_EMA_INDEX]
- Hold-cost controller: 5-line formula in `training_loop.rs` (per-step, after observer)
- Reward composition: per-bar subtraction at `experience_kernels.cu` reward composition
site when action == Hold
- 2 GPU oracle tests for the controller (target tracking, deficit response)
**No changes to** (vs v2 P0a): action space dimension, `ExposureLevel` enum, Q-head dim,
C51/IQN bucket loops, action packing, replay buffer encoding, HEALTH_DIAG val_dir_dist
buckets, SP5 Pearl 8 trail-dist, 30+ stale-doc audit. The 4-way world stays.
**Tension with `pearl_event_driven_reward_density_alignment`** (acknowledged):
the pearl warns against per-bar reward shaping creating exposure-positive bias toward
dense-signal optimization. Per-bar Hold cost is the inverse case — exposure-NEGATIVE
shaping that models real economic carry cost (broker fees, margin interest, opportunity
cost). It's faithful reward modeling, not artificial shaping. ISV-bounded by controller
target so cost can't run away. We KEEP the pearl's intent (don't add fake dense rewards
the policy will optimize against the objective) while pricing a real economic friction.
### Change 2: Direction-skill bonus (bounded)
@@ -218,7 +253,7 @@ the fix, not a confounder, because the SP11 formula was structurally wrong).
## ISV slot allocation
8 new slots in `[372..380)`:
11 new slots in `[372..383)` (8 from v2 + 3 added in v3 for Hold-pricing):
| Slot | Name | Purpose | Reset |
|---|---|---|---|
@@ -230,8 +265,18 @@ the fix, not a confounder, because the SP11 formula was structurally wrong).
| 377 | `DIR_SKILL_BONUS_BETA_INDEX` | Skill bonus penalty (wrong direction), default 1.0 | per-fold |
| 378 | `LUCK_WIN_DISCOUNT_INDEX` | Lucky-win discount factor, default 0.3 | per-fold |
| 379 | `SKILL_BONUS_CAP_RATIO_INDEX` | Cap ratio: bonus ≤ ratio × \|alpha\|, default 0.3 | per-fold |
| 380 | `HOLD_COST_INDEX` | Per-bar Hold cost (controller output), default `HOLD_COST_BASE` | per-fold |
| 381 | `HOLD_RATE_TARGET_INDEX` | Target Hold-pick rate (default 0.20) | static |
| 382 | `HOLD_RATE_OBSERVED_EMA_INDEX` | Observed Hold-pick rate EMA (α=0.05) | per-fold |
Bump `SP5_SLOT_END = 380`, `ISV_TOTAL_DIM = 380`.
Defaults: `HOLD_COST_BASE = 0.001` (small per-bar; cumulative across long Hold runs
becomes significant). `HOLD_RATE_TARGET_DEFAULT = 0.20`. Sentinel for slot 382: 0.0
(zero observed Hold-picks at fold start).
Bump `SP5_SLOT_END = 383`, `ISV_TOTAL_DIM = 383`.
T1 already staged with `SP5_SLOT_END = 380` (8 slots). T3 v3 work bumps to 383
additively (slots 380-382 are net-new in T3, no T1 rework).
**Slot 371 (was BENCHMARK_PNL_CUMULATIVE_INDEX in v1) — DROPPED.** Per-fold buy-and-hold
delta is computed at fold-end in HEALTH_DIAG emit (trivial: `price[fold_end]
@@ -239,31 +284,44 @@ price[fold_start]`). No kernel, no slot, no reward dependency.
## Implementation phases
### Phase 0a — Hold elimination only (1 atomic commit)
### Phase 0a — Hold-pricing + dir_acc instrumentation (1 atomic commit, additive)
**Purpose**: clean test of user's hypothesis. Hold removed; aux_w controller, dir_acc
instrumentation, skill bonus, aux classification all UNCHANGED from current main.
**Purpose**: clean test of user's hypothesis. Hold-pricing forces the policy to commit
when no edge exists (CQL bias can no longer make Hold the lazy default), while
preserving Hold for legitimate MFT use. aux_w controller, skill bonus, aux
classification all UNCHANGED from current main (those land in P0b/B/C).
- ISV slot 372 (target, static), 373 (short EMA), 374 (long EMA), 375 (aux prediction —
but produced by the existing regression aux head, just `tanh(scalar_pred)` for now to
fit the [1, +1] interface)
- `aux_dir_acc_reduce_kernel` — single-block tree-reduce of (correct_count, pos_pred,
pos_label, valid_count) → 3 output floats per epoch
- HEALTH_DIAG `aux_dir_acc accuracy=… pos_pred=… pos_label=…` per epoch
- Hold elimination atomic refactor (state_layout, Q-head, action select, C51/IQN bucket
state, replay encoding, val_dir_dist, KL, Rust enum)
- ~300 LOC
- 11 ISV slots (372-382): dir_acc tracking (372-374), aux prediction (375), Layer C
reward composition slots (376-379, populated but unused until Layer C), and the v3
Hold-pricing trio (380-382).
- T1 (already staged): 8 slots scaffolding (372-379), state-reset registry, constructor
static-init.
- T2 (already staged): `aux_dir_acc_reduce_kernel` + 6 GPU oracle tests.
- **T3 v3** (replaces v2's Hold-elimination): adds 3 Hold-pricing slots (380-382),
Hold-rate observer kernel, Hold-cost controller, per-bar reward subtraction site,
2 GPU oracle tests for the controller. ~120 LOC, NO contract change.
- T4: wire dir_acc launch + HEALTH_DIAG `aux_dir_acc` + `hold_cost` + `observed_hold_rate`
emit per epoch.
- T5: atomic commit + 10-epoch L40S smoke.
**Phase 0a smoke** (10-epoch L40S):
-**Pass**: `val_dir_acc[ep9] > val_dir_acc[ep1]` AND `val_dir_acc[ep9] > 0.52` AND
`val_win_rate[ep9] > 0.51` → user's hypothesis confirmed; proceed Layer B.
- ⚠️ **Partial** (one trending up, the other flat): proceed P0b.
- ❌ **Both flat** (`dir_acc < 0.51` AND `win_rate < 0.50` after 10 epochs): proceed P0b
to add aux amplification. If P0b also fails, halt SP13.
`val_win_rate[ep9] > 0.51` AND `observed_hold_rate ≤ 0.30` hypothesis confirmed;
priced Hold + remaining policy commits to directions; proceed Layer B.
- ⚠️ **Partial** (one trending up, the other flat, OR Hold rate stuck > 0.40 despite
controller escalation): proceed P0b for aux amplification.
-**All flat** (dir_acc < 0.51 AND win_rate < 0.50 AND Hold-rate stuck high): proceed
P0b. If P0b also fails, halt SP13 — data lacks signal at this timescale, plan SP14.
10 epochs (not 5) because aux head regression has been suppressed for entire SP1-SP12
lineage — needs time even at unchanged aux_w to show whether signal is recoverable.
Cost: ~€0.60.
lineage — needs time at unchanged aux_w to show whether signal is recoverable. Cost: ~€0.60.
**Why this is a sharper test than v2 elimination**: v2 forced ALL Hold-equivalent
behavior (the model couldn't pick Hold). v3 lets the model still pick Hold, but only
when the perceived edge justifies the cost. If model converges to a low Hold-rate (≤
30%) AND WR climbs, both conditions pass — clean confirmation. If model maintains high
Hold-rate despite escalating cost AND WR stays flat, the data lacks signal (the model
prefers paying for Hold over committing because committing has no edge).
### Phase 0b — Add corrected aux_w controller (only if 0a partial/red, atomic commit)