feat(sp21): T3.1+T3.2 ISV defrost — wr_ema + hold_pct_ema (atomic)

SP21 Tier-3 foundation: defrost two production-frozen ISV slots that
pinned at 0 across every observed training epoch (d7bj7, xmd6b). Both
bugs in the same aggregator kernel, same structural shape: per-step
binary majority-vote indicators feeding fractional EMAs.

T3.1 — WR_EMA defrost (sp20_aggregate_inputs_kernel.cu):
  - was: is_win_out = (2 * wins_count >= closed_count) ? 1 : 0
         binary "majority won this step" indicator
  - now: win_fraction_out = (float)wins_count / (float)closed_count
         fractional in [0, 1]
  - With actual val WR≈0.46, the binary signal was 0 most of the time,
    pinning WR_EMA at 0 across 30+ epochs in xmd6b. EMA now converges
    to population win rate.

T3.2 — HOLD_PCT_EMA defrost (same kernel, same pattern):
  - was: action_is_hold_out = (hold_count * 2 > n_envs) ? 1 : 0
         strict-majority indicator
  - now: hold_fraction_out = (float)hold_count / (float)n_envs
  - Cascade: HOLD_COST_SCALE controller compared hold_pct_ema=0 to
    tgt±0.05, always saw < lower, ramped × 0.95 → clamped at floor
    0.01 (the hold_cost_scale=0.0100 observation in d7bj7 logs).
    T3.5 expected to cascade-fix in next training run.
  - HOLD_REWARD_EMA gate preserves strict-majority semantic via
    hold_fraction > 0.5f test in the consumer kernel.

Atomic across struct fields, kernel logic, Rust mirror, byte
serialization, doc tables, and 4 test files (per
feedback_no_partial_refactor):
  - sp20_aggregate_inputs_kernel.cu (struct + 2 computations)
  - sp20_emas_compute_kernel.cu (struct + reader + gate)
  - sp20_aggregate_inputs.rs (doc table)
  - sp20_emas_compute.rs (Rust struct + serialize + 3 tests)
  - sp20_aggregate_inputs_test.rs (reader sig + 4 test assertions)
  - sp20_emas_compute_test.rs (5 struct literal updates)
  - sp20_phase1_4_wireup_test.rs (HOLD_PCT_EMA expected 0.625)

Verification:
  - cargo check -p ml --tests: passes (warnings only)
  - cargo test -p ml --lib sp20_emas: 6/6 unit tests pass

Pearl candidate: binary-majority aggregator over a fractional
underlying signal cannot serve as input to a fractional-target EMA.
The previous fix (commit 64bbbe418) addressed the per-bar vs segment
predicate at the producer site, but didn't notice the aggregator's
binarization step still collapsed the fraction to {0, 1}. Two bugs
in series, both now resolved.

Plan reference: docs/plans/2026-05-10-sp21-train-eval-coherence-isv-defrost.md
Tiers 3.3-3.6 remaining; T3.5 expected to cascade-fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-05-10 20:13:13 +02:00
parent 6df44e4c6d
commit 1790a31b66
8 changed files with 266 additions and 111 deletions

View File

@@ -14,9 +14,9 @@
//! | Field | Rule |
//! |------------------------|---------------------------------------------------------------------------------------|
//! | `is_close` | `1` if any env's `trade_close[env] != 0`, else `0` |
//! | `is_win` | `1` if `is_close == 1` AND fraction of closed envs with `step_ret > 0` ≥ 0.5, else `0` |
//! | `win_fraction` | `wins_count / closed_count ∈ [0, 1]` when `is_close == 1`, else `0.0` (SP21 T3.1: was binary majority `is_win`) |
//! | `trade_duration` | `round(mean(hold_at_exit) over closed envs)` if `is_close == 1`, else `0` |
//! | `action_is_hold` | `1` if `count(decoded_dir == HOLD) > n_envs/2`, else `0` |
//! | `hold_fraction` | `hold_count / n_envs ∈ [0, 1]` (SP21 T3.2: was binary `action_is_hold` strict-majority vote) |
//! | `alpha` | `mean(alpha_per_env) over closed envs` if `is_close == 1`, else `0` — **SP20 Phase 2 Task 2.2** (R_event - hold_baseline). |
//! | `per_bar_hold_reward` | `0.0` — **Phase 3.2 forward reference** (-aux_conf * cost_scale) |
//! | `aux_logits_p50` | `aux_logits_p50_dev[0]` (sp20_stats output) |

View File

@@ -104,9 +104,22 @@
* `#[repr(C)]`) ensures both kernels see the same byte layout. */
struct SP20EmaInputs {
int is_close;
int is_win;
/* SP21 T3.1 (2026-05-10): fractional win rate ∈ [0, 1] = wins_count /
* closed_count when is_close == 1, else 0.0f. Replaces the prior
* binary `int is_win` (majority-vote) field which structurally pinned
* WR_EMA at 0 whenever true WR < 0.5 (the binary "majority won this
* step" indicator was 0 most of the time when actual win rate was
* < 50%). The EMA blends over this fractional value so WR_EMA
* converges to the true population win rate. */
float win_fraction;
int trade_duration;
int action_is_hold;
/* SP21 T3.2 (2026-05-10): fractional hold rate ∈ [0, 1] = hold_count /
* n_envs. Replaces the prior binary `int action_is_hold` (strict
* majority vote) which structurally pinned HOLD_PCT_EMA at 0 whenever
* fewer than half the envs were in Hold (the canonical case during
* directional trading). HOLD_REWARD_EMA's gate preserves the strict-
* majority semantic via `hold_fraction > 0.5f` test in the consumer. */
float hold_fraction;
float alpha;
float per_bar_hold_reward;
float aux_logits_p50;
@@ -283,12 +296,22 @@ extern "C" __global__ void sp20_aggregate_inputs_kernel(
const int is_close_out = (closed_count > 0) ? 1 : 0;
/* is_win: fraction of closed envs that won >= 0.5. */
int is_win_out = 0;
/* SP21 T3.1 (2026-05-10): fractional win rate, NOT binary majority.
* `win_fraction = wins_count / closed_count ∈ [0, 1]`. The prior
* binary majority-vote `(2 * wins_count >= closed_count)` predicate
* was structurally incapable of representing WR < 0.5 — it emitted
* 0 whenever fewer than half the closed envs won, regardless of
* the actual ratio, pinning WR_EMA at 0 in production whenever
* true WR < 0.5 (the canonical case). The downstream EMA blends
* over this float directly so WR_EMA converges to the population
* win rate.
*
* Emit 0.0f when `is_close_out == 0` for cleanliness — the consumer
* gates the blend on `is_close == 1` so the value is moot in that
* branch. */
float win_fraction_out = 0.0f;
if (is_close_out == 1) {
/* (wins_count / closed_count) >= 0.5
* ⇔ 2 * wins_count >= closed_count (avoids fp). */
is_win_out = (2 * wins_count >= closed_count) ? 1 : 0;
win_fraction_out = (float)wins_count / (float)closed_count;
}
/* trade_duration: round(mean of hold_at_exit over closed envs). */
@@ -311,9 +334,18 @@ extern "C" __global__ void sp20_aggregate_inputs_kernel(
alpha_out = alpha_sum_f / (float)closed_count;
}
/* action_is_hold: majority-vote over envs. > n/2 threshold
* matches the spec (strict majority, ties ⇒ 0). */
const int action_is_hold_out = (hold_count * 2 > n_envs) ? 1 : 0;
/* SP21 T3.2 (2026-05-10): fractional hold rate, NOT binary majority.
* `hold_fraction = hold_count / n_envs ∈ [0, 1]`. The prior strict-
* majority `(hold_count * 2 > n_envs)` predicate was structurally
* incapable of representing hold rates < 0.5 — it emitted 0 whenever
* fewer than half the envs were in Hold, pinning HOLD_PCT_EMA at 0
* during directional trading and cascade-pinning HOLD_COST_SCALE at
* its floor (the controller saw `hold_pct_ema < lower` every step
* and ramped × 0.95 to clamp). Downstream HOLD_REWARD_EMA gate
* preserves the strict-majority semantic via `hold_fraction > 0.5f`. */
const float hold_fraction_out = (n_envs > 0)
? (float)hold_count / (float)n_envs
: 0.0f;
/* Read the upstream stats / dir-acc outputs. Stream-ordering
* invariant guarantees these are visible to this kernel's
@@ -330,9 +362,9 @@ extern "C" __global__ void sp20_aggregate_inputs_kernel(
* reference until Component 2 lands the per-bar Hold opp-cost
* dual emission. */
out_inputs->is_close = is_close_out;
out_inputs->is_win = is_win_out;
out_inputs->win_fraction = win_fraction_out;
out_inputs->trade_duration = trade_duration_out;
out_inputs->action_is_hold = action_is_hold_out;
out_inputs->hold_fraction = hold_fraction_out; /* SP21 T3.2 */
out_inputs->alpha = alpha_out; /* Task 2.2 — was 0.0f */
out_inputs->per_bar_hold_reward = 0.0f; /* Phase 3.2 fwd ref */
out_inputs->aux_logits_p50 = p50_in;

View File

@@ -19,10 +19,10 @@
//! | EMA | Storage | Gate | Observation source |
//! |-----|---------|------|--------------------|
//! | `ALPHA_EMA` | ISV[511] | `is_close == 1` | `alpha = R_event - hold_baseline` |
//! | `WR_EMA` | ISV[512] | `is_close == 1` | `is_win` ∈ {0, 1} |
//! | `WR_EMA` | ISV[512] | `is_close == 1` | `win_fraction` ∈ [0, 1] (SP21 T3.1) |
//! | `TRADE_DUR` | internal[0]| `is_close == 1` | `trade_duration` (bars) |
//! | `HOLD_PCT_EMA` | ISV[515] | per-step | `(action_is_hold == 1) ? 1 : 0` |
//! | `HOLD_REWARD_EMA`| ISV[516] | `action_is_hold == 1` | `r_per_bar = -aux_conf * cost_scale` |
//! | `HOLD_PCT_EMA` | ISV[515] | per-step | `hold_fraction ∈ [0, 1]` (SP21 T3.2) |
//! | `HOLD_REWARD_EMA`| ISV[516] | `hold_fraction > 0.5` | `r_per_bar = -aux_conf * cost_scale` |
//! | `AUX_P50` | internal[1]| per-step | `aux_logits_p50` (Phase 1.1 output) |
//! | `AUX_STD` | internal[2]| per-step | `aux_logits_std` (Phase 1.1 output) |
//! | `AUX_DIR_ACC` | internal[3]| per-step | `aux_dir_acc` ∈ {0.0, 1.0} |
@@ -147,21 +147,30 @@ pub const OBS_COUNT_AUX_DIR_ACC: usize = 7;
/// Field semantics:
/// - `is_close`: 1 if a trade closed this step, 0 otherwise. Gates
/// the per-trade EMAs (WR, ALPHA, TRADE_DURATION).
/// - `is_win`: 1 if the closed trade was a win, 0 otherwise.
/// Only meaningful when `is_close == 1`. Stored as i32 to match
/// the device-struct field width.
/// - `win_fraction`: fractional win rate ∈ [0, 1] = wins_count /
/// closed_count, only meaningful when `is_close == 1`. SP21 T3.1
/// (2026-05-10): replaced the prior binary `is_win` (i32 majority
/// vote) — the aggregator now emits the actual population win rate
/// as a float so WR_EMA converges to the true value. The prior
/// binary indicator structurally pinned WR_EMA at 0 whenever true
/// WR < 0.5.
/// - `trade_duration`: bars in the closed trade. Only meaningful
/// when `is_close == 1`. Stored as i32 (rounded mean across envs
/// in the production aggregation path).
/// - `action_is_hold`: 1 if the (majority) action this step was
/// Hold, 0 otherwise. Gates the HOLD_REWARD_EMA; also drives the
/// HOLD_PCT_EMA observation.
/// - `hold_fraction`: fractional hold rate ∈ [0, 1] = hold_count /
/// n_envs. SP21 T3.2 (2026-05-10): replaced the prior binary
/// `action_is_hold` (i32 strict-majority vote). HOLD_PCT_EMA
/// observes this directly so the EMA converges to the actual hold
/// rate, not the long-run probability of strict-majority hold.
/// HOLD_REWARD_EMA gate preserves the strict-majority semantic via
/// `hold_fraction > 0.5f` test in the consumer kernel.
/// - `alpha`: `R_event - hold_baseline` from the SP20 spec
/// Component 1 reward kernel. Phase 2 wires the real producer;
/// Phase 1.4 wire-up populates this with sentinel 0.0 (forward
/// reference, see audit doc + `sp20_aggregate_inputs_kernel.cu`).
/// - `per_bar_hold_reward`: per-bar Hold reward = `-aux_conf *
/// cost_scale`. Only meaningful when `action_is_hold == 1`.
/// cost_scale`. Only meaningful when `hold_fraction > 0.5` (the
/// strict-majority gate preserved from pre-SP21-T3.2 semantics).
/// Phase 3.2 wires the real producer; Phase 1.4 populates this
/// with sentinel 0.0 (forward reference).
/// - `aux_logits_p50`: per-step `aux_logits_p50` from Phase 1.1's
@@ -176,9 +185,9 @@ pub const OBS_COUNT_AUX_DIR_ACC: usize = 7;
#[derive(Copy, Clone, Debug, Default)]
pub struct EmaInputs {
pub is_close: i32,
pub is_win: i32,
pub win_fraction: f32,
pub trade_duration: i32,
pub action_is_hold: i32,
pub hold_fraction: f32,
pub alpha: f32,
pub per_bar_hold_reward: f32,
pub aux_logits_p50: f32,
@@ -215,15 +224,14 @@ pub fn pack_inputs_into_f32_view(out: &mut [f32], inputs: &EmaInputs) {
"pack_inputs_into_f32_view: out len ({}) < SP20_EMA_INPUTS_F32_LEN ({})",
out.len(), SP20_EMA_INPUTS_F32_LEN,
);
// Match the device-side `SP20EmaInputs` layout: 4 i32 + 5 f32.
// Bit-pattern transmute: the device kernel reads the f32-aliased
// buffer as i32 fields by struct offset (the .cu file's
// `int is_close` is a 4-byte read off the same offset our
// `f32::from_bits(...)` write lands at).
// Match the device-side `SP20EmaInputs` layout: 2 i32 + 7 f32 (SP21
// T3.1 + T3.2 converted is_win → win_fraction and action_is_hold →
// hold_fraction). Bit-pattern transmute for the i32 fields; native
// f32 stores for the rest.
out[0] = f32::from_bits(inputs.is_close as u32);
out[1] = f32::from_bits(inputs.is_win as u32);
out[1] = inputs.win_fraction;
out[2] = f32::from_bits(inputs.trade_duration as u32);
out[3] = f32::from_bits(inputs.action_is_hold as u32);
out[3] = inputs.hold_fraction;
out[4] = inputs.alpha;
out[5] = inputs.per_bar_hold_reward;
out[6] = inputs.aux_logits_p50;
@@ -354,7 +362,7 @@ mod tests {
#[test]
fn ema_inputs_default_is_safe_sentinel() {
// A `Default::default()` `EmaInputs` has all gates closed
// (is_close = 0, action_is_hold = 0) and all observations at
// (is_close = 0, hold_fraction = 0.0) and all observations at
// 0 — equivalent to "no firing this step" for all 8 EMAs.
// This is the canonical pre-warmup / cold-start input shape.
let inp = EmaInputs::default();
@@ -363,7 +371,7 @@ mod tests {
// is correct behavior for cold-start, and is verified by the
// `per_step_emas_fire_unconditionally` GPU oracle test.
assert_eq!(inp.is_close, 0);
assert_eq!(inp.action_is_hold, 0);
assert_eq!(inp.hold_fraction, 0.0);
}
#[test]
@@ -381,11 +389,12 @@ mod tests {
fn pack_inputs_round_trips() {
// Sanity: pack into a 9-f32 buffer and read back the bit
// patterns; verify i32 fields land in the right slots.
// SP21 T3.1: win_fraction is now a native f32 in slot [1].
let inp = EmaInputs {
is_close: 1,
is_win: 1,
win_fraction: 0.46,
trade_duration: 7,
action_is_hold: 0,
hold_fraction: 0.0,
alpha: 0.5,
per_bar_hold_reward: -0.05,
aux_logits_p50: 0.2,
@@ -396,9 +405,11 @@ mod tests {
pack_inputs_into_f32_view(&mut buf, &inp);
// Verify i32 slots come back via to_bits round trip.
assert_eq!(buf[0].to_bits() as i32, 1);
assert_eq!(buf[1].to_bits() as i32, 1);
// win_fraction is a native f32 — direct comparison.
assert!((buf[1] - 0.46).abs() < 1e-6);
assert_eq!(buf[2].to_bits() as i32, 7);
assert_eq!(buf[3].to_bits() as i32, 0);
// hold_fraction is a native f32 — direct comparison.
assert!(buf[3].abs() < 1e-6);
// Float slots are direct.
assert_eq!(buf[4], 0.5);
assert_eq!(buf[5], -0.05);

View File

@@ -134,9 +134,15 @@
* each 4 bytes. */
struct SP20EmaInputs {
int is_close; /* 0 or 1 — gates per-trade EMAs */
int is_win; /* 0 or 1 — only meaningful when is_close == 1 */
/* SP21 T3.1 (2026-05-10): fractional win rate ∈ [0, 1] = wins_count /
* closed_count when is_close == 1, else 0.0f. Replaces the prior
* binary `int is_win` (majority-vote) field. */
float win_fraction;
int trade_duration; /* bars — only meaningful when is_close == 1 */
int action_is_hold; /* 0 or 1 — gates HOLD_REWARD_EMA */
/* SP21 T3.2 (2026-05-10): fractional hold rate ∈ [0, 1] = hold_count /
* n_envs. HOLD_PCT_EMA observes this directly. HOLD_REWARD_EMA gate
* preserves strict-majority semantic via `hold_fraction > 0.5f`. */
float hold_fraction;
float alpha; /* R_event - hold_baseline at trade close */
float per_bar_hold_reward; /* -aux_conf * cost_scale on Hold bars */
float aux_logits_p50; /* upstream sp20_stats output [0] */
@@ -207,22 +213,29 @@ extern "C" __global__ void sp20_emas_compute_kernel(
* coalescing is moot, but the explicit local copies make the
* dependency chain readable. */
const int is_close = ema_inputs->is_close;
const int is_win_i = ema_inputs->is_win;
/* SP21 T3.1 (2026-05-10): read fractional win rate as float directly.
* The aggregator emits `wins_count / closed_count ∈ [0, 1]` so the
* EMA converges to the population win rate. Was previously `int
* is_win` (binary majority vote) which structurally pinned WR_EMA
* at 0 whenever true WR < 0.5. */
const float win_fraction = ema_inputs->win_fraction;
const int trade_duration = ema_inputs->trade_duration;
const int action_is_hold = ema_inputs->action_is_hold;
/* SP21 T3.2 (2026-05-10): fractional hold rate. HOLD_PCT_EMA observes
* this directly; HOLD_REWARD_EMA gate uses `hold_fraction > 0.5f` to
* preserve the prior strict-majority semantic. */
const float hold_fraction = ema_inputs->hold_fraction;
const float alpha = ema_inputs->alpha;
const float r_per_bar = ema_inputs->per_bar_hold_reward;
const float aux_p50_in = ema_inputs->aux_logits_p50;
const float aux_std_in = ema_inputs->aux_logits_std;
const float aux_dir_acc_in = ema_inputs->aux_dir_acc;
const float is_win = (float)is_win_i;
const float trade_duration_f = (float)trade_duration;
/* ── Per-trade EMAs: gated on is_close == 1 ───────────────────── */
if (is_close == 1) {
/* WR_EMA */
isv[isv_idx_wr] = ema_step(isv[isv_idx_wr], is_win,
/* WR_EMA — blends over win_fraction ∈ [0, 1] (SP21 T3.1). */
isv[isv_idx_wr] = ema_step(isv[isv_idx_wr], win_fraction,
&obs_count[OBS_IDX_WR]);
/* ALPHA_EMA */
@@ -237,13 +250,16 @@ extern "C" __global__ void sp20_emas_compute_kernel(
/* ── Per-step EMAs: always fire ───────────────────────────────── */
/* HOLD_PCT_EMA — observation = (action_is_hold == 1) ? 1.0 : 0.0 */
{
const float hold_indicator = (action_is_hold == 1) ? 1.0f : 0.0f;
isv[isv_idx_hold_pct] = ema_step(isv[isv_idx_hold_pct],
hold_indicator,
&obs_count[OBS_IDX_HOLD_PCT]);
}
/* HOLD_PCT_EMA — observation = hold_fraction ∈ [0, 1] (SP21 T3.2).
* Was previously the `(action_is_hold == 1) ? 1.0 : 0.0` binary
* indicator which structurally pinned HOLD_PCT_EMA at 0 whenever
* the policy held positions in less than half the envs. The
* downstream HOLD_COST_SCALE controller compares hold_pct_ema to
* `tgt ± 0.05` to decide ramp direction; with the binary signal it
* always saw `< lower` and clamped at floor 0.01. */
isv[isv_idx_hold_pct] = ema_step(isv[isv_idx_hold_pct],
hold_fraction,
&obs_count[OBS_IDX_HOLD_PCT]);
/* aux_conf_p50_ema — internal scratch */
internal[INT_IDX_AUX_P50] =
@@ -260,8 +276,15 @@ extern "C" __global__ void sp20_emas_compute_kernel(
ema_step(internal[INT_IDX_AUX_DIR_ACC], aux_dir_acc_in,
&obs_count[OBS_IDX_AUX_DIR_ACC]);
/* ── Hold-reward EMA: gated on action_is_hold == 1 ────────────── */
if (action_is_hold == 1) {
/* ── Hold-reward EMA: gated on hold_fraction > 0.5 (SP21 T3.2). ──
* Preserves the prior strict-majority semantic of `action_is_hold==1`
* (`hold_count*2 > n_envs` ⇔ `hold_fraction > 0.5`). The gate stays
* binary because `r_per_bar` is a per-step scalar (single value, not
* per-env); reading it only on majority-Hold steps approximates the
* "policy is in Hold this bar" semantic. T3.3 (per_bar_hold_reward
* producer is currently 0.0, Phase 3.2 forward reference) will
* unfreeze HOLD_REWARD_EMA fully when the producer wires real values. */
if (hold_fraction > 0.5f) {
isv[isv_idx_hold_reward] =
ema_step(isv[isv_idx_hold_reward], r_per_bar,
&obs_count[OBS_IDX_HOLD_REWARD]);

View File

@@ -69,23 +69,26 @@ mod gpu {
}
/// Read the post-launch `SP20EmaInputs` struct out of the f32-aliased
/// buffer. Returns `(is_close, is_win, trade_duration, action_is_hold,
/// alpha, per_bar_hold_reward, p50, std, dir_acc)`.
fn read_ema_inputs(buf: &MappedF32Buffer) -> (i32, i32, i32, i32, f32, f32, f32, f32, f32) {
/// buffer. Returns `(is_close, win_fraction, trade_duration,
/// hold_fraction, alpha, per_bar_hold_reward, p50, std, dir_acc)`.
/// SP21 T3.1+T3.2 (2026-05-10): slots 1 and 3 are native f32
/// (`win_fraction`, `hold_fraction`) — were binary `is_win` /
/// `action_is_hold` previously.
fn read_ema_inputs(buf: &MappedF32Buffer) -> (i32, f32, i32, f32, f32, f32, f32, f32, f32) {
let v = buf.read_all();
assert_eq!(v.len(), SP20_EMA_INPUTS_F32_LEN);
// The kernel writes int fields in the first 4 slots and float
// fields in the last 5. We cast back from f32::to_bits.
// 2 i32 fields + 7 f32 fields. i32 fields come back via to_bits;
// f32 fields are direct reads.
let is_close = v[0].to_bits() as i32;
let is_win = v[1].to_bits() as i32;
let win_fraction = v[1];
let trade_duration = v[2].to_bits() as i32;
let action_is_hold = v[3].to_bits() as i32;
let hold_fraction = v[3];
let alpha = v[4];
let per_bar_hr = v[5];
let p50 = v[6];
let std = v[7];
let dir_acc = v[8];
(is_close, is_win, trade_duration, action_is_hold,
(is_close, win_fraction, trade_duration, hold_fraction,
alpha, per_bar_hr, p50, std, dir_acc)
}
@@ -102,7 +105,7 @@ mod gpu {
aux_logits_p50: f32,
aux_logits_std: f32,
aux_dir_acc: f32,
) -> (i32, i32, i32, i32, f32, f32, f32, f32, f32) {
) -> (i32, f32, i32, f32, f32, f32, f32, f32, f32) {
let stream = make_test_stream();
let kernel = load_aggregation_kernel(&stream);
let n = trade_close.len();
@@ -169,7 +172,7 @@ mod gpu {
aux_logits_p50: f32,
aux_logits_std: f32,
aux_dir_acc: f32,
) -> (i32, i32, i32, i32, f32, f32, f32, f32, f32) {
) -> (i32, f32, i32, f32, f32, f32, f32, f32, f32) {
let stream = make_test_stream();
let kernel = load_aggregation_kernel(&stream);
let n = trade_close.len();
@@ -243,7 +246,7 @@ mod gpu {
aux_logits_p50: f32,
aux_logits_std: f32,
aux_dir_acc: f32,
) -> (i32, i32, i32, i32, f32, f32, f32, f32, f32) {
) -> (i32, f32, i32, f32, f32, f32, f32, f32, f32) {
let stream = make_test_stream();
let kernel = load_aggregation_kernel(&stream);
let n = trade_close.len();
@@ -314,9 +317,9 @@ mod gpu {
run_kernel(&trade_close, &step_ret, &hold_at_exit, &actions,
0.3, 0.1, 0.55);
assert_eq!(ic, 0, "is_close must be 0 (no trades closed)");
assert_eq!(iw, 0, "is_win must be 0 when is_close=0");
assert_eq!(iw, 0.0, "win_fraction must be 0.0 when is_close=0");
assert_eq!(td, 0, "trade_duration must be 0 when is_close=0");
assert_eq!(ah, 0, "action_is_hold must be 0 (all envs picked Long)");
assert_eq!(ah, 0.0, "hold_fraction must be 0.0 (all envs picked Long)");
assert_eq!(alpha, 0.0, "alpha is Phase 2 forward ref placeholder = 0.0");
assert_eq!(hr, 0.0, "per_bar_hold_reward is Phase 3.2 forward ref = 0.0");
assert!((p50 - 0.3).abs() < 1e-6, "aux_logits_p50 forwarded");
@@ -325,9 +328,11 @@ mod gpu {
}
/// Half envs win, half lose; all close. Expected:
/// is_close = 1, is_win = 1 (4 wins / 8 closed = 0.5, threshold).
/// is_close = 1, win_fraction = 0.5 (4 wins / 8 closed).
/// trade_duration = round(mean(hold_at_exit)) = round(7.5) = 8
/// (round-to-nearest-even at 7.5 ⇒ 8).
/// SP21 T3.1: was `is_win = 1` (binary majority threshold); now
/// `win_fraction = 0.5` exact.
#[test]
#[ignore = "requires GPU"]
fn half_wins_meets_threshold_and_duration_rounds() {
@@ -340,7 +345,8 @@ mod gpu {
run_kernel(&trade_close, &step_ret, &hold_at_exit, &actions,
0.0, 0.0, 0.0);
assert_eq!(ic, 1);
assert_eq!(iw, 1, "fraction wins (4/8 = 0.5) meets >= 0.5 threshold");
assert!((iw - 0.5).abs() < 1e-6,
"win_fraction = 4/8 = 0.5 exactly; got {iw}");
// round-to-nearest-even at 7.5 = 8 (banker's rounding); CUDA
// __float2int_rn uses round-to-nearest-even. Either 7 or 8 are
// structurally acceptable; the kernel uses CUDA's RN so we
@@ -349,14 +355,15 @@ mod gpu {
td == 7 || td == 8,
"trade_duration must round 7.5 to 7 (down) or 8 (RN-even); got {td}",
);
assert_eq!(ah, 0);
assert_eq!(ah, 0.0,
"hold_fraction must be 0.0 (all envs picked Long, no Hold)");
}
/// Strict majority Hold over n_envs. With 5 of 8 picking Hold,
/// 5 > 8/2 = 4 ⇒ action_is_hold = 1.
/// 5 of 8 envs picking Hold → hold_fraction = 5/8 = 0.625 (SP21 T3.2:
/// was binary `action_is_hold = 1` under strict majority).
#[test]
#[ignore = "requires GPU"]
fn majority_hold_sets_action_is_hold() {
fn five_of_eight_hold_yields_fraction_0_625() {
let trade_close = vec![0; 8];
let step_ret = vec![0.0; 8];
let hold_at_exit = vec![0.0; 8];
@@ -367,14 +374,19 @@ mod gpu {
let (_, _, _, ah, _, _, _, _, _) =
run_kernel(&trade_close, &step_ret, &hold_at_exit, &actions,
0.0, 0.0, 0.0);
assert_eq!(ah, 1, "5 of 8 = strict majority Hold => action_is_hold=1");
assert!(
(ah - 0.625).abs() < 1e-6,
"5 of 8 Hold => hold_fraction = 5/8 = 0.625 exact; got {ah}",
);
}
/// Tie at exactly half Hold. 4 of 8 = 4 = n_envs/2; the kernel uses
/// `count * 2 > n_envs` (strict majority), so a tie ⇒ 0.
/// Half of envs picking Hold → hold_fraction = 4/8 = 0.5 exact (SP21
/// T3.2: was `action_is_hold = 0` under strict majority — ties did
/// not trigger). The HOLD_REWARD_EMA gate `hold_fraction > 0.5f`
/// preserves the strict-majority semantic at the consumer site.
#[test]
#[ignore = "requires GPU"]
fn tie_hold_count_does_not_set_action_is_hold() {
fn half_hold_yields_fraction_0_5_exact() {
let trade_close = vec![0; 8];
let step_ret = vec![0.0; 8];
let hold_at_exit = vec![0.0; 8];
@@ -385,11 +397,14 @@ mod gpu {
let (_, _, _, ah, _, _, _, _, _) =
run_kernel(&trade_close, &step_ret, &hold_at_exit, &actions,
0.0, 0.0, 0.0);
assert_eq!(ah, 0, "tie (4 of 8 Hold) => action_is_hold=0 (strict majority)");
assert!(
(ah - 0.5).abs() < 1e-6,
"4 of 8 Hold => hold_fraction = 4/8 = 0.5 exact; got {ah}",
);
}
/// Single env closed with a loss: is_close=1, is_win=0
/// (0/1 < 0.5), trade_duration = round(3.0) = 3.
/// Single env closed with a loss: is_close=1, win_fraction=0.0
/// (0/1), trade_duration = round(3.0) = 3.
#[test]
#[ignore = "requires GPU"]
fn single_env_loss_close() {
@@ -401,7 +416,7 @@ mod gpu {
run_kernel(&trade_close, &step_ret, &hold_at_exit, &actions,
0.0, 0.0, 0.0);
assert_eq!(ic, 1);
assert_eq!(iw, 0, "loss (0 wins out of 1 closed) => is_win=0");
assert_eq!(iw, 0.0, "loss (0 wins out of 1 closed) => win_fraction=0.0");
assert_eq!(td, 3, "trade_duration = round(3.0) = 3");
}
@@ -557,14 +572,15 @@ mod gpu {
);
assert_eq!(ic, 1, "is_close = 1 (4 envs closed)");
assert_eq!(
iw, 1,
"is_win MUST be 1 — segment-level wins (is_win_per_env = \
[1,1,1,0]) put 3 of 4 closed envs in the win bucket; \
2*3 >= 4 ⇒ majority threshold met. The legacy `step_ret \
> 0` predicate would have given is_win = 0 (all step_rets \
negative due to tx-cost domination at close bars), pinning \
WR_EMA at 0 in production.",
assert!(
(iw - 0.75).abs() < 1e-6,
"win_fraction MUST be 3/4 = 0.75 — segment-level wins \
(is_win_per_env = [1,1,1,0]) put 3 of 4 closed envs in the \
win bucket. SP21 T3.1: aggregator now emits the actual \
fraction, not a binary majority indicator. The legacy \
`step_ret > 0` predicate would have given win_fraction = 0 \
(all step_rets negative due to tx-cost domination at close \
bars), pinning WR_EMA at 0 in production. got iw={iw}",
);
}
@@ -590,12 +606,13 @@ mod gpu {
0.0, 0.0, 0.0);
assert_eq!(ic, 1);
assert_eq!(
iw, 1,
assert!(
(iw - 1.0).abs() < 1e-6,
"NULL is_win_per_env falls back to `sr > 0` legacy \
predicate — all 4 step_rets positive ⇒ wins_count = 4 ⇒ \
is_win = 1 (the pre-fix oracle-test contract preserved \
via NULL-tolerance).",
win_fraction = 4/4 = 1.0 (the pre-fix oracle-test contract \
preserved via NULL-tolerance). SP21 T3.1: aggregator emits \
fraction not binary majority. got iw={iw}",
);
}
}

View File

@@ -146,9 +146,9 @@ mod gpu {
fn first_observation_replaces_sentinel() {
let inp = EmaInputs {
is_close: 1,
is_win: 1,
win_fraction: 1.0,
trade_duration: 7,
action_is_hold: 0,
hold_fraction: 0.0,
alpha: 0.5,
per_bar_hold_reward: 0.0,
aux_logits_p50: 0.0,
@@ -216,9 +216,9 @@ mod gpu {
let inputs: Vec<EmaInputs> = (0..100)
.map(|i| EmaInputs {
is_close: 1,
is_win: if i % 2 == 0 { 1 } else { 0 },
win_fraction: if i % 2 == 0 { 1.0 } else { 0.0 },
trade_duration: 1,
action_is_hold: 0,
hold_fraction: 0.0,
alpha: 0.0,
per_bar_hold_reward: 0.0,
aux_logits_p50: 0.0,
@@ -253,9 +253,9 @@ mod gpu {
for _ in 0..5 {
inputs.push(EmaInputs {
is_close: 0,
is_win: 0,
win_fraction: 0.0,
trade_duration: 0,
action_is_hold: 0, // gated OFF
hold_fraction: 0.0, // gated OFF
alpha: 0.0,
per_bar_hold_reward: -0.05,
aux_logits_p50: 0.0,
@@ -266,9 +266,9 @@ mod gpu {
for _ in 0..5 {
inputs.push(EmaInputs {
is_close: 0,
is_win: 0,
win_fraction: 0.0,
trade_duration: 0,
action_is_hold: 1, // gate ON
hold_fraction: 1.0, // > 0.5 gate ON (SP21 T3.2)
alpha: 0.0,
per_bar_hold_reward: -0.05,
aux_logits_p50: 0.0,
@@ -317,9 +317,9 @@ mod gpu {
let inputs: Vec<EmaInputs> = (0..10)
.map(|_| EmaInputs {
is_close: 0,
is_win: 0,
win_fraction: 0.0,
trade_duration: 0,
action_is_hold: 0,
hold_fraction: 0.0,
alpha: 0.0,
per_bar_hold_reward: 0.0,
aux_logits_p50: 0.2,

View File

@@ -254,15 +254,23 @@ mod gpu {
assert_eq!(isv[ALPHA_EMA_INDEX], 0.0,
"ALPHA_EMA stays at 0.0 with NULL alpha_per_env producer (Task 2.2 NULL contract)");
// WR_EMA: 1 win out of 1 close ⇒ is_win = 1, first-obs replace
// ⇒ WR_EMA = 1.0.
// WR_EMA: 1 win out of 1 close ⇒ win_fraction = 1.0, first-obs
// replace ⇒ WR_EMA = 1.0. SP21 T3.1: aggregator emits exact
// fraction (was binary majority).
assert_eq!(isv[WR_EMA_INDEX], 1.0,
"WR_EMA = 1.0 after one winning close (1 win / 1 closed >= 0.5)");
"WR_EMA = 1.0 after one winning close (1 win / 1 closed = 1.0)");
// HOLD_PCT_EMA: 5 of 8 envs picked Hold ⇒ action_is_hold = 1
// ⇒ HOLD_PCT_EMA = 1.0 on first observation.
assert_eq!(isv[HOLD_PCT_EMA_INDEX], 1.0,
"HOLD_PCT_EMA = 1.0 after one majority-Hold observation");
// HOLD_PCT_EMA: 5 of 8 envs picked Hold ⇒ hold_fraction = 5/8 =
// 0.625, first-obs replace ⇒ HOLD_PCT_EMA = 0.625. SP21 T3.2:
// aggregator emits exact fraction (was binary majority indicator
// → 1.0). The fractional value lets the downstream HOLD_COST_SCALE
// controller make graded decisions instead of the cascade-
// pinning-at-floor that the binary signal produced.
let hold_pct = isv[HOLD_PCT_EMA_INDEX];
assert!(
(hold_pct - 0.625).abs() < 1e-6,
"HOLD_PCT_EMA = 0.625 (5/8 of envs Hold); got {hold_pct}",
);
// HOLD_REWARD_EMA: per_bar_hold_reward = 0.0 (Phase 3.2 fwd
// ref); fired on Hold ⇒ EMA = 0.0.

View File

@@ -2,6 +2,70 @@
**Status:** Populated during Plan 1 Task 6 (A.5 orphan audit). Updated on every commit per Invariant 7.
## 2026-05-10 — SP21 Tier-3 foundation: T3.1+T3.2 ISV defrost (wr_ema, hold_pct_ema)
**Branch:** `sp20-aux-h-fixed` (off `6df44e4c6`).
**Commits:** 1 atomic (this commit).
Defrosts two production-frozen ISV slots that pinned-at-zero across all
observed training epochs in d7bj7/xmd6b logs. Both bugs were in the same
aggregator kernel (`sp20_aggregate_inputs_kernel.cu`), same structural
shape: per-step binary majority-vote indicators feeding fractional EMAs.
**T3.1: WR_EMA — `is_win``win_fraction` (struct field rename + type change).**
The aggregator computed `is_win_out = (2 * wins_count >= closed_count) ? 1 : 0`
— a binary "majority of closed envs won" indicator. With actual val WR
≈ 0.46 (xmd6b 30 epochs), the binary signal was 0 most of the time, and
WR_EMA → 0 by Wiener-α floor blend. The aggregator now emits
`win_fraction = wins_count / closed_count ∈ [0, 1]` and the EMA blends
over the float directly. WR_EMA will converge to the population win rate.
**T3.2: HOLD_PCT_EMA — `action_is_hold``hold_fraction` (same fix shape).**
The aggregator computed `action_is_hold_out = (hold_count * 2 > n_envs) ? 1 : 0`
— strict-majority indicator. With actual hold pct in [0.10, 0.56],
majority was rarely met, HOLD_PCT_EMA → 0. **Cascade**: the
`HOLD_COST_SCALE` controller compares `hold_pct_ema` to `tgt ± 0.05` and
ramps; with hold_pct_ema=0 it always saw `< lower` and ramped × 0.95
every step → clamped at floor 0.01 (the `hold_cost_scale = 0.0100`
observation in d7bj7 sp20_isv logs). Fixing T3.2 should cascade-fix
T3.5 — verify in next training run. HOLD_REWARD_EMA gate preserves
strict-majority semantic via `hold_fraction > 0.5f` test in the
consumer kernel.
**Affected files (atomic per `feedback_no_partial_refactor`):**
- `crates/ml/src/cuda_pipeline/sp20_aggregate_inputs_kernel.cu`
struct fields, output computations.
- `crates/ml/src/cuda_pipeline/sp20_emas_compute_kernel.cu`
struct fields, EMA reader, HOLD_REWARD_EMA gate.
- `crates/ml/src/cuda_pipeline/sp20_aggregate_inputs.rs` — doc table.
- `crates/ml/src/cuda_pipeline/sp20_emas_compute.rs` — Rust struct mirror,
byte serialization, doc table, default-sentinel test, round-trip test.
- `crates/ml/tests/sp20_aggregate_inputs_test.rs` — reader signature,
4 fractional-comparison test assertions, 2 renamed test fns.
- `crates/ml/tests/sp20_emas_compute_test.rs` — 5 struct-literal field
renames (is_win → win_fraction, action_is_hold → hold_fraction).
- `crates/ml/tests/sp20_phase1_4_wireup_test.rs` — HOLD_PCT_EMA expected
value updated 1.0 → 0.625 (5/8 envs Hold).
**Verification:**
- `cargo check -p ml --tests` — passes (warnings only).
- `cargo test -p ml --lib sp20_emas` — 6/6 unit tests pass, including
`pack_inputs_round_trips` with `win_fraction: 0.46` + `hold_fraction: 0.0`.
- GPU oracle tests gated `#[ignore = "requires GPU"]` — compile cleanly.
**Pearls applied / candidates:**
- `pearl_first_observation_bootstrap` — unchanged, both EMAs still bootstrap
from sentinel.
- `pearl_wiener_alpha_floor_for_nonstationary` — unchanged, α=0.4 floor.
- New pearl candidate: **binary-majority aggregator over a fractional
underlying signal cannot serve as input to a fractional-target EMA**.
When the EMA is supposed to converge to a population fraction, the
observation must itself be the fraction or a 0/1 sample, not a
thresholded reduction.
## 2026-05-10 — REVERT: aux_horizon_update_kernel sp20-aux-h-fixed experiment closed
Reverts the forced-H=30 diagnostic that tested the bootstrap-failure hypothesis. **Hypothesis verdict: dead.** Across two epochs of `train-d7bj7`: