fix(sp11): B1b bug-hunt fix-up — stale rc[] init + cf_flip ordering

Bug-hunt review on 5e16b67ca found two real bugs in experience_env_step
post-B1b structural refactor — same class as the slot 63 overload
(pre-SP11 invariants exposed by post-decomposition semantic).

Bug 1 (Critical): reward_components_per_sample[+1..+5] not zero-init at
per-bar entry. Slots written only on execution paths that fire (rc[2]
inside segment_complete trail-trigger, rc[3] inside positioned-non-
complete, rc[4] inside flat-with-features, rc[5] inside multiple
bonus paths, rc[1] inside CF block). Non-firing paths leave stale
values from previous batch's same out_off, contaminating SP4
reward_component_ema_kernel -> slots 64..68 -> SP11 mag-ratio canary
-> wrong controller weights.

Fix: zero-init rc[0..6) at the same location where r_<component>
locals are zero-initialized, so locals and buffer reset together.
Eliminates the entire stale-rc class of bugs.

Bug 2 (Important): cf_flip applied BEFORE inventory/churn/conviction
modifiers. Spec section 3.4.4 requires cf_flip LAST so subtractive
penalties operate on the right sign and multiplicative scales
attenuate before direction is flipped. Pre-fix on flipped samples,
inventory/churn ADDED to negated reward (penalty became bonus) and
conviction scaled the wrong-signed value. Asymmetric gradient signals
between flipped/non-flipped -> contaminated SP11 mag-ratio canary at
the cf axis.

Fix: move cf_flip to after conviction_scale, making it the last
modifier before out_rewards write. CF-block contract preserved
(reward at cf_off is still the post-flipped final value; CF block
already does `do_flip ? -reward : reward` to recover unflipped base).

Stale doc-strings: state_reset_registry.rs (2 sites) and
training_loop.rs (1 site) referenced pre-fix-up SP5_PRODUCER_COUNT=186
/ wiener_buffer=771. Updated to formula form
`(71 + SP5_PRODUCER_COUNT) × 3` so they don't drift on the next SP;
current value (post-B1b fix-up slot 360) is SP5_PRODUCER_COUNT=187,
buffer=774 floats.

dqn-wire-up-audit.md: appended SP11 B1b bug-hunt fix-up section
documenting both bugs, the structural fix, the doc-string sweep,
and verification (cargo check/build/tests).

cargo check + build clean; 6/6 SP11 GPU tests + 10/10 sp5_isv_slots
contract tests + 4/4 state_reset_registry tests still pass. The two
bugs were latent in the local smoke (sharpe 3 vs B1a 30 likely
traceable to either or both); structural fix sound. L40S smoke on
this commit will validate the empirical recovery.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-05-04 10:51:55 +02:00
parent 5e16b67ca6
commit b435d25bec
4 changed files with 141 additions and 15 deletions

View File

@@ -2629,6 +2629,41 @@ extern "C" __global__ void experience_env_step(
float r_opp_cost = 0.0f;
float r_bonus = 0.0f;
/* Zero-init per-component slots so non-firing paths don't leave stale
* data from the previous batch's `out_off` contaminating the SP4 reward-
* component EMAs (slots 64..68) and the SP11 mag-ratio canary that reads
* them. The local `r_*` variables are zero-initialized above; the backing
* buffer needs the same treatment so non-firing paths produce a true zero
* observation rather than stale prior-bar values.
*
* Slot population is conditional and sparse:
* rc[1] = cf_reward_weighted — written ONLY inside the CF block (~L3648)
* rc[2] = r_trail — written ONLY inside the segment_complete
* trail-trigger path (~L2730)
* rc[3] = r_micro — written ONLY inside positioned-non-
* complete paths (~L3006, L3016)
* rc[4] = r_opp_cost — written ONLY inside flat-with-features
* path (~L3121)
* rc[5] = bonus_* — overwrite at ~L3169, multiple `+=` /
* `-=` sites elsewhere
*
* Without this zero-init, a bar that doesn't fire the relevant path
* retains values from the previous batch's same `out_off` → reward-
* component EMAs (slots 64..68) accumulate stale magnitudes → SP11 mag-
* ratio canary reads contaminated ratios → controller emits wrong
* weights. Same class as the slot 63 overload bug fixed via B1b fix-up:
* pre-SP11 invariants exposed by post-decomposition semantic.
*
* Note: rc[+0] (PopArt input = total reward) is unconditionally
* overwritten at ~L3447, so it doesn't strictly need zero-init here, but
* including it removes ordering dependency for future readers. */
reward_components_per_sample[out_off * 6 + 0] = 0.0f;
reward_components_per_sample[out_off * 6 + 1] = 0.0f;
reward_components_per_sample[out_off * 6 + 2] = 0.0f;
reward_components_per_sample[out_off * 6 + 3] = 0.0f;
reward_components_per_sample[out_off * 6 + 4] = 0.0f;
reward_components_per_sample[out_off * 6 + 5] = 0.0f;
/* Trade-cumulative running scalar — tracks the in-progress reward
* across the if/else cascade. C.4/D.4b bonus blocks read this to
* Q-cap their `|reward|` multiplicand (the existing Q-bounded
@@ -3395,16 +3430,20 @@ extern "C" __global__ void experience_env_step(
* the replay buffer. This prevents gradient explosion from propagating. */
if (isnan(reward) || isinf(reward)) reward = 0.0f;
/* G7: Counterfactual reward flip — negate reward when features were flipped.
* The model learns that flipped features -> flipped reward, enforcing
* directional symmetry. */
if (do_flip) {
reward *= -1.0f;
}
/* ── Cost-driven hold timing (replaces min_hold_bars) ──────────────
* The temporal attention learns optimal hold timing from these signals.
* No hardcoded gates — the model sees costs and learns to minimize them. */
* No hardcoded gates — the model sees costs and learns to minimize them.
*
* Modifier ordering (SP11 spec §3.4.4):
* clamp_capital → -drawdown → -inventory → -churn → ×conviction → ×cf_flip
*
* cf_flip MUST be the LAST modifier so subtractive penalties operate on
* the correct sign and the multiplicative conviction scale attenuates
* before the direction is flipped. Pre-fix, on flipped samples
* inventory/churn ADDED to the negated reward (penalty became bonus)
* and conviction scaled the wrong-signed value — asymmetric gradient
* signals between flipped and non-flipped samples contaminated the
* SP11 mag-ratio canary at the cf axis. */
/* Inventory penalty: continuous cost proportional to position size.
* Teaches: small positions cheap to hold, large positions expensive.
@@ -3433,6 +3472,15 @@ extern "C" __global__ void experience_env_step(
reward *= conviction;
}
/* G7: Counterfactual reward flip — negate reward when features were flipped.
* The model learns that flipped features -> flipped reward, enforcing
* directional symmetry. Per spec §3.4.4 this is the LAST modifier so all
* additive penalties and the multiplicative conviction operate on the
* correctly-signed reward before the direction is inverted. */
if (do_flip) {
reward *= -1.0f;
}
out_rewards[out_off] = reward;
out_dones[out_off] = (float)done;

View File

@@ -534,7 +534,7 @@ impl StateResetRegistry {
RegistryEntry {
name: "sp4_wiener_state",
category: ResetCategory::FoldReset,
description: "GpuDqnTrainer.wiener_state_buf [(71 + SP5_PRODUCER_COUNT) × 3 floats; post-SP11: (71 + 186) × 3 = 771] = (SP4 + SP5/SP6/SP7/SP8/SP9/SP10/SP11 producers) × {sample_var, diff_var, x_lag}] — SP4 Pearl D Wiener-EMA state for SP4 block [0..213) (40 SP4 + 29 Task-A13 retrofit + 2 Layer-C-#260 producers). Mapped-pinned f32; bulk write_bytes(0) at fold boundary covers the full buffer [0..(71 + SP5_PRODUCER_COUNT) × 3) so Pearl A's sentinel branch fires on the new fold's first producer launch for ALL SP4 and SP5+ producers (`prev_x_mean == 0 AND state.x_lag == 0` → first-observation replacement). Companion to the 11 SP4 ISV bound entries above — both halves of the sentinel contract reset together per `feedback_no_partial_refactor.md`. Buffer size derives from SP5_PRODUCER_COUNT to avoid drift on each subsequent SP.",
description: "GpuDqnTrainer.wiener_state_buf [(71 + SP5_PRODUCER_COUNT) × 3 floats; total = (SP4 + SP5/SP6/SP7/SP8/SP9/SP10/SP11 producers) × {sample_var, diff_var, x_lag}] — SP4 Pearl D Wiener-EMA state for SP4 block [0..213) (40 SP4 + 29 Task-A13 retrofit + 2 Layer-C-#260 producers). Mapped-pinned f32; bulk write_bytes(0) at fold boundary covers the full buffer [0..(71 + SP5_PRODUCER_COUNT) × 3) so Pearl A's sentinel branch fires on the new fold's first producer launch for ALL SP4 and SP5+ producers (`prev_x_mean == 0 AND state.x_lag == 0` → first-observation replacement). Companion to the 11 SP4 ISV bound entries above — both halves of the sentinel contract reset together per `feedback_no_partial_refactor.md`. Buffer size derives from SP5_PRODUCER_COUNT (formula form, not a hardcoded literal) to avoid drift on each subsequent SP.",
},
RegistryEntry {
name: "sp4_clamp_engage_counters",
@@ -903,14 +903,19 @@ impl StateResetRegistry {
// SP5 Task A1: Wiener-state companion reset. The wiener_state_buf
// covers SP4 (SP4_PRODUCER_COUNT=71 producers × 3 = 213 floats) +
// SP5 (SP5_PRODUCER_COUNT × 3 floats) = (71 + SP5_PRODUCER_COUNT) × 3
// floats total. With SP5_PRODUCER_COUNT=186 (post-SP11 Fix 39) the
// buffer is 771 floats; the SP5 triples start at offset 213.
// floats total. The SP5 triples start at offset 213 and span
// SP5_PRODUCER_COUNT × 3 floats; the runtime constant lives in
// `cuda_pipeline::sp5_isv_slots::SP5_PRODUCER_COUNT` and grows as
// each subsequent SP adds producers (B1b fix-up landed slot 360 →
// SP5_PRODUCER_COUNT=187 → buffer = 774 floats, SP5 block
// [213..774)). Always derive sizes via the formula
// `(71 + SP5_PRODUCER_COUNT) × 3` rather than committing the literal.
// Both halves of the Pearl A sentinel contract must reset together per
// `feedback_no_partial_refactor.md`.
RegistryEntry {
name: "sp5_wiener_state",
category: ResetCategory::FoldReset,
description: "GpuDqnTrainer.wiener_state_buf SP5 block at offsets [SP4_PRODUCER_COUNT × 3 .. (SP4_PRODUCER_COUNT + SP5_PRODUCER_COUNT) × 3) = [213..(213 + SP5_PRODUCER_COUNT × 3)). The total buffer is sized via `SP5_WIENER_TOTAL_FLOATS = (SP4_PRODUCER_COUNT=71 + SP5_PRODUCER_COUNT) × 3` floats; with SP5_PRODUCER_COUNT=186 (current value, post-SP11 Fix 39) the buffer is 771 floats and the SP5 block is [213..771). Each producer triple = {sample_var, diff_var, x_lag}. SP5 Pearl D Wiener-EMA state for ALL SP5 producers (Pearls 1, 2, 3, 4, 5, Layer D D1/D2/D3, SP7 T1, SP7 activation-flag fix, SP8 Fix 36, SP9 Kelly cold-start, SP10 eval Thompson temperature, SP11 reward-subsystem controller). Bulk write_bytes(0) at fold boundary covers the full wiener_state_buf [0..SP5_WIENER_TOTAL_FLOATS) so Pearl A's sentinel branch fires on the new fold's first producer launch. Pearl 6 (slots 280..286) does NOT use this buffer — Pearl 6 is cross-fold-persistent and uses in-kernel EWMA + cumulative-via-max(); the wiener slots [525..543) that Pearl 6 would map to under naive offset arithmetic (213 + (280-174)*3 = 525) are intentionally unused. Both SP4 and SP5 [non-Pearl-6] blocks reset atomically.",
description: "GpuDqnTrainer.wiener_state_buf SP5 block at offsets [SP4_PRODUCER_COUNT × 3 .. (SP4_PRODUCER_COUNT + SP5_PRODUCER_COUNT) × 3) = [213..(213 + SP5_PRODUCER_COUNT × 3)). The total buffer is sized via `SP5_WIENER_TOTAL_FLOATS = (SP4_PRODUCER_COUNT=71 + SP5_PRODUCER_COUNT) × 3` floats; refer to the runtime `SP5_PRODUCER_COUNT` (`cuda_pipeline::sp5_isv_slots`) for the current value rather than hardcoding a literal here — it grows on each SP follow-up (e.g. B1b fix-up landed slot 360 → SP5_PRODUCER_COUNT=187 → buffer=774 floats, SP5 block [213..774)). Each producer triple = {sample_var, diff_var, x_lag}. SP5 Pearl D Wiener-EMA state for ALL SP5 producers (Pearls 1, 2, 3, 4, 5, Layer D D1/D2/D3, SP7 T1, SP7 activation-flag fix, SP8 Fix 36, SP9 Kelly cold-start, SP10 eval Thompson temperature, SP11 reward-subsystem controller). Bulk write_bytes(0) at fold boundary covers the full wiener_state_buf [0..SP5_WIENER_TOTAL_FLOATS) so Pearl A's sentinel branch fires on the new fold's first producer launch. Pearl 6 (slots 280..286) does NOT use this buffer — Pearl 6 is cross-fold-persistent and uses in-kernel EWMA + cumulative-via-max(); the wiener slots [525..543) that Pearl 6 would map to under naive offset arithmetic (213 + (280-174)*3 = 525) are intentionally unused. Both SP4 and SP5 [non-Pearl-6] blocks reset atomically.",
},
// SP5 Task A6: Pearl 6 cross-fold-persistent Kelly cap signals.
// ISV[280..286) are EXPLICITLY EXEMPT from this registry.

View File

@@ -6906,9 +6906,13 @@ impl DQNTrainer {
// No-op: `reset_sp4_wiener_state` already bulk-zeros the
// FULL `wiener_state_buf [0..(71 + SP5_PRODUCER_COUNT) × 3)`
// which includes the SP5 producer block at offsets
// [71 × 3 .. (71 + SP5_PRODUCER_COUNT) × 3). Post-SP11
// (SP5_PRODUCER_COUNT = 186): buffer is 771 floats, SP5
// block at [213..771). The registry entry exists for
// [71 × 3 .. (71 + SP5_PRODUCER_COUNT) × 3). Buffer size
// is parameterised on the runtime `SP5_PRODUCER_COUNT`
// constant (`cuda_pipeline::sp5_isv_slots`) and grows as
// each SP follow-up adds producers — do NOT hardcode a
// float count here, it will go stale (B1b fix-up landed
// slot 360 → SP5_PRODUCER_COUNT=187 → buffer = 774 floats,
// SP5 block [213..774)). The registry entry exists for
// documentation symmetry with the SP4 entry; the actual
// byte-zero is performed by the SP4 arm. Pearl 6 (slots
// 280..286) is intentionally exempt — its wiener-state

View File

@@ -5232,3 +5232,72 @@ update needed. B1c will pass `STATE_DIM_PADDED as i32` for the
Pre-requisite for B1b (structural reward composition) and B1c
(replay-time curiosity wiring against `trainer.states_buf`).
### SP11 B1b bug-hunt fix-up (2026-05-04, on top of `5e16b67ca`)
Bug-hunt review on the post-B1b kernel found two real bugs in
`experience_env_step` reward composition. Both are the same class
as the slot 63 overload bug fixed via the slot-360 follow-up:
pre-SP11 invariants exposed by post-decomposition semantic. Both
fixed atomically per `feedback_no_partial_refactor.md`.
Bug 1 (Critical) — stale `reward_components_per_sample[+1..+5]`:
the per-component slots `rc[1..5]` are written only on execution
paths that fire (`rc[1]` inside the CF block, `rc[2]` inside the
`segment_complete` trail-trigger, `rc[3]` inside positioned-non-
complete, `rc[4]` inside flat-with-features, `rc[5]` inside multiple
bonus paths). Bars whose execution path doesn't fire retained values
from the previous batch's same `out_off`, contaminating the SP4
`reward_component_ema_kernel` writes at ISV[64..68) and the SP11
mag-ratio canary that reads them — the controller emitted the wrong
weights as a result. Fix: zero-init `rc[0..6)` at per-bar entry
(line ~2660), at the same site where the `r_<component>` locals
are zero-initialized so locals + buffer reset together. Component
0 is unconditionally overwritten downstream so it doesn't strictly
need the zero-init, but including it eliminates ordering dependency
for future readers.
Bug 2 (Important) — cf_flip ordering: per spec section 3.4.4 the
modifier order is
`clamp_capital -> -drawdown -> -inventory -> -churn -> ×conviction
-> ×cf_flip`. Pre-fix-up the `if (do_flip) reward *= -1.0f;` block
ran at the NaN-guard site BEFORE inventory/churn/conviction. On
flipped samples this caused inventory/churn (subtractive penalties)
to ADD to the negated reward (penalty became bonus) and conviction
to scale the wrong-signed value — asymmetric gradient signal between
flipped and non-flipped samples in the same batch, contaminating the
SP11 mag-ratio canary at the cf axis. Fix: move the cf_flip block
to AFTER the conviction multiplicand, making it the last modifier
before the `out_rewards` write. The CF block downstream still reads
the post-flipped final `reward` and uses `do_flip ? -reward : reward`
to recover the unflipped base — the CF contract is preserved, only
the underlying composition sequence changed (and that's the intended
fix).
Stale doc-strings updated to formula form to prevent next-SP drift:
- `crates/ml/src/trainers/dqn/state_reset_registry.rs` lines 537
and 905-915 (sp4_wiener_state, sp5_wiener_state SP5 block comment,
sp5_wiener_state RegistryEntry description).
- `crates/ml/src/trainers/dqn/trainer/training_loop.rs` lines
6905-6917 (sp5_wiener_state dispatch arm comment).
All three sites previously hardcoded `SP5_PRODUCER_COUNT=186 / buffer
=771`; post-B1b fix-up the values are 187/774, but the doc-strings
should never have committed the literal — they now reference the
runtime constant via the formula `(71 + SP5_PRODUCER_COUNT) × 3`.
Verification:
- `SQLX_OFFLINE=true cargo check -p ml --lib` — clean
- `SQLX_OFFLINE=true cargo build -p ml --release` — clean
- `SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 cargo test -p ml --test
sp11_producer_unit_tests --features cuda -- --ignored` — 6/6 GPU
oracle tests pass on RTX 3050 Ti
- `SQLX_OFFLINE=true cargo test -p ml --lib sp5_isv_slots` —
10/10 contract tests pass
- `SQLX_OFFLINE=true cargo test -p ml --lib state_reset_registry` —
4/4 contract tests pass
L40S smoke on this commit will validate the empirical sharpe-recovery
(local B1b smoke regressed from B1a sharpe ~30 to ~3; both bugs above
are plausible root causes). After this fix-up + L40S verification,
B1c (replay-time curiosity wiring) is unblocked.