feat(sp13): P0a atomic — Hold-pricing + dir_acc instrumentation (additive)

Tests user's hypothesis (Hold being FREE is the bug, not Hold itself) by
pricing Hold via ISV-driven adaptive controller targeting 20% Hold-rate.

11 new SP13 ISV slots [372..383). 5 new GPU kernels:
- aux_dir_acc_reduce_kernel.cu (correct/pos_pred/pos_label/valid → 3 scalars)
- hold_rate_observer_kernel.cu (packed batch_actions decode, count(Hold)/B)
- apply_fixed_alpha_ema_kernel.cu (preserves short/long timescale split that
  Wiener-optimal apply_pearls_ad_kernel would collapse)
- aux_pred_to_isv_tanh_kernel.cu (mean(tanh(aux_pred)) → ISV[375])
- 3 reward-composition sites in experience_kernels.cu subtract isv[HOLD_COST]
  on Hold actions (segment_complete pre-asymmetric-cap, positioned-non-event
  per-bar, flat per-bar)

Host-side controller in training_loop.rs:
  excess = max(0, observed - target)
  hold_cost = HOLD_COST_BASE × (1 + 5 × excess), clamped [0.5×, 5.0×base]

Per-step observer + EMA chain in gpu_experience_collector.rs after
experience_action_select. Per-epoch HEALTH_DIAG emit:
  aux_dir_acc target/short/long/pred_tanh
  hold_pricing observed_rate/target/cost

4-way action space stays (ExposureLevel::Hold preserved). Replay buffer /
fxcache compatibility preserved. SP11 (11/11) + SP12 (14/14) tests no
regression. SP13 P0a oracle tests: 14/14 on RTX 3050 Ti.

Spec/plan: docs/superpowers/{specs,plans}/2026-05-04-sp13-redefine-success-for-predictive-skill.md (v3)
Audit: docs/dqn-wire-up-audit.md (SP13 P0a section appended)

v2 → v3 reframe: P0a.T3 v2 implementer's audit found DirectionAction enum
doesn't exist (codebase uses 8-variant fused ExposureLevel cascading through
77 files). v3 reframes from "eliminate Hold" (250 LOC + 32-test cascade) to
"price Hold" (additive, no contract change, no cross-crate cascade).

Tension with pearl_event_driven_reward_density_alignment acknowledged in spec
— per-bar Hold cost is exposure-NEGATIVE (pulls policy AWAY from Hold-default,
inverse of the pearl's failure mode), models real economic carry, ISV-bounded
by controller. 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-05 00:52:54 +02:00
parent ba83fcd1f5
commit f934ea1719
16 changed files with 2385 additions and 36 deletions

View File

@@ -663,6 +663,63 @@ fn main() {
// EPS_DIV) mirror sp4_wiener_ema.rs's host-reference for the
// GPU-vs-host oracle test.
"apply_pearls_kernel.cu",
// SP13 Phase 0a (2026-05-04): aux-head directional-accuracy
// reducer. Single-block tree-reduce kernel scoring the aux next-
// bar regression head's prediction sign against the next-bar
// return label sign. Four shared-memory int arrays
// (correct/pos_pred/pos_label/valid) reduce in lockstep — no
// atomicAdd per `feedback_no_atomicadd.md`. Outputs three
// batch-level fractions (dir_acc, pos_pred_frac, pos_label_frac)
// into a 3-element mapped-pinned buffer. P0a reads regression-
// mode aux scalar; Layer B swaps the source pointer to
// `softmax[1] - softmax[0]` after the head becomes 2-class
// softmax. Consumed by `launch_aux_dir_acc_reduce` in
// `gpu_dqn_trainer.rs`.
"aux_dir_acc_reduce_kernel.cu",
// SP13 v3 Phase 0a P0a.T3 (2026-05-04): Hold-rate observer.
// Single-block tree-reduce kernel counting per-bar Hold-action
// picks (decoded from packed `batch_actions[i]` via
// `dir = action_idx / (NUM_MAGNITUDES * NUM_ORD * NUM_URG)` —
// P0a.T4 ABI alignment, 2026-05-04) and writing the fraction
// `count(Hold) / batch_size` to a 1-element mapped-pinned buffer.
// One shared-memory int array reduces in lockstep — no atomicAdd
// per `feedback_no_atomicadd.md`. The fixed-α EMA applicator
// smooths the observation into ISV[HOLD_RATE_OBSERVED_EMA_INDEX=382];
// the host-side controller reads ISV[382] + ISV[381] and writes the
// priced Hold cost back to ISV[HOLD_COST_INDEX=380]. The reward
// composition site in `experience_kernels.cu` then subtracts the
// cost on every Hold-action bar — pricing the action so the
// policy uses Hold deliberately rather than as a free CQL-bias-
// anchored default. Replaces v2's atomic Hold elimination per
// the v3 reframe (preserves the 4-way action space — no cross-
// crate cascade). Consumed by `launch_hold_rate_observer` in
// `gpu_dqn_trainer.rs`.
"hold_rate_observer_kernel.cu",
// SP13 Phase 0a P0a.T4 (2026-05-04): fixed-α EMA applicator.
// Sibling of `apply_pearls_kernel` — needed because Pearls A+D
// collapse two EMAs of the same signal to identical values
// (variance ratio is α-independent), which would deadlock the
// SP13 stagnation detector that compares slots 373 (α=0.3) vs
// 374 (α=0.05). Per-thread first-observation-sentinel bootstrap
// (caller passes the slot's registry sentinel — 0.5 for dir-acc,
// 0.0 for hold-rate) plus a fixed-α blend. No atomicAdd per
// `feedback_no_atomicadd.md`; pure GPU compute per
// `feedback_no_cpu_compute_strict.md`. Consumed by
// `launch_apply_fixed_alpha_ema` in `gpu_dqn_trainer.rs`.
"apply_fixed_alpha_ema_kernel.cu",
// SP13 Phase 0a P0a.T4 (2026-05-04): aux-head per-bar prediction
// → ISV[AUX_DIR_PREDICTION_INDEX=375] tanh-bounded scalar
// producer. Single-block tree-reduce reads the captured-graph
// `aux_nb_pred_buf [B]` tile and writes
// `mean(tanh(aux_pred[i]))` ∈ [-1, +1] to the SHARED ISV slot 375
// (per-step state, not an EMA — slot is overwritten each launch).
// tanh squash bounds the scalar so the downstream Q-head
// consumer sees a well-conditioned signal regardless of
// `label_scale` magnitude. No atomicAdd per
// `feedback_no_atomicadd.md`; pure GPU compute per
// `feedback_no_cpu_compute_strict.md`. Consumed by
// `launch_aux_pred_to_isv_tanh` in `gpu_dqn_trainer.rs`.
"aux_pred_to_isv_tanh_kernel.cu",
];
// ALL kernels get common header (BF16 types + wrappers)

View File

@@ -0,0 +1,93 @@
// crates/ml/src/cuda_pipeline/apply_fixed_alpha_ema_kernel.cu
//
// SP13 Phase 0a P0a.T4 (2026-05-04): fixed-alpha EMA applicator.
//
// Sibling of `apply_pearls_ad_kernel.cu` (the SP4 Wiener-optimal Pearls
// A+D applicator) for cases where the EMA needs DISTINCT TIMESCALES
// instead of a Wiener-optimal blend. SP13 Phase 0a needs both a SHORT
// (α=0.3) and a SLOW (α=0.05) EMA of the aux-head directional accuracy:
// the stagnation detector compares the two to decide whether `aux_w`
// should keep ramping or relax back toward the base. Pearl D would
// collapse both EMAs to the same value because the underlying signal is
// the same and the variance ratio doesn't depend on α — so the slot
// pair would report `short - long ≈ 0` permanently and the controller
// could never read "improving" or "stalled". Fixed-α EMAs preserve the
// timescale separation by construction.
//
// First-observation bootstrap per `pearl_first_observation_bootstrap`:
// when `prev_state == sentinel` the kernel REPLACES `prev_state` with
// the new sample directly (rather than blending `(1-α)·sentinel + α·obs`
// which would carry the sentinel into the EMA forever for the
// fold's first non-zero observation). The sentinel is passed as a
// kernel arg because each consumer slot has its own (slot 373 / 374
// use 0.5 — the random-guessing baseline for binary directional
// accuracy; slot 382 uses 0.0 — the empty-batch fallback for the
// hold-rate observer). The kernel has no compile-time knowledge of
// the slot's reset semantics; the launcher passes the same sentinel
// the registry entry uses on fold reset.
//
// One launch updates N consecutive ISV slots, each with its own
// (sample[i], prev_isv[isv_offset+i]) pair. For SP13 P0a: N=1 per
// EMA (one scalar dir-acc fed into both slots 373 and 374; two
// launches in lockstep). The kernel parallelises across slots —
// each thread updates one slot — so larger N (e.g. per-batch tile
// reductions) just scales the grid; no inter-thread coordination
// needed.
//
// Single-block, BLOCK_SIZE up to 256 (sized at launch from N).
// Per-thread bootstrap test + blend; no shared memory, no atomicAdd
// (per `feedback_no_atomicadd.md`). Pure GPU compute per
// `feedback_no_cpu_compute_strict.md`. Stream-ordered with the
// producer that wrote `sample[]`; the launcher MUST issue this
// kernel on the SAME stream so the producer's `__threadfence_system()`
// orders against the read here.
#include <cuda_runtime.h>
extern "C" __global__ void apply_fixed_alpha_ema_kernel(
/* New per-slot observations [N]. The producer kernel that wrote
* this buffer MUST have issued `__threadfence_system()` before
* returning, so this thread's read is well-ordered against the
* write. */
const float* __restrict__ sample,
/* Number of slots to update (the launcher's grid covers `N`
* threads). N=1 is the common SP13 case (a single dir-acc scalar
* fanned out into two ISV slots via two launches with different α). */
int n,
/* Offset into the ISV array where this kernel begins writing. The
* caller's slot indices are `[isv_offset, isv_offset + N)`. */
int isv_offset,
/* Fixed blend rate. SP13: 0.3 for the short EMA (slot 373), 0.05
* for the slow EMA (slot 374) and the hold-rate EMA (slot 382). */
float alpha,
/* Sentinel value for first-observation bootstrap. When
* `isv[isv_offset + i] == sentinel` (per `pearl_first_observation_bootstrap`),
* the kernel replaces directly with `sample[i]` instead of blending.
* 0.5 for the SP13 dir-acc EMAs (slots 373/374), 0.0 for the
* hold-rate EMA (slot 382). */
float sentinel,
/* ISV array — both read (prev state) and write (new state) at
* `[isv_offset, isv_offset + N)`. */
float* __restrict__ isv)
{
const int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i >= n) return;
const int slot = isv_offset + i;
const float prev = isv[slot];
const float new_obs = sample[i];
/* Pearl A first-observation bootstrap: replace directly when the
* stored state still matches the registry sentinel. For an EMA
* whose sentinel is the new fold's "no-observation-yet" baseline
* (0.5 for dir-acc, 0.0 for hold-rate), blending α·obs with the
* sentinel would keep the sentinel's bias in the EMA across the
* fold boundary. Direct replacement on the first real observation
* gives the new fold a clean start. */
const float new_state = (prev == sentinel)
? new_obs
: (1.0f - alpha) * prev + alpha * new_obs;
isv[slot] = new_state;
__threadfence_system();
}

View File

@@ -0,0 +1,144 @@
// crates/ml/src/cuda_pipeline/aux_dir_acc_reduce_kernel.cu
//
// SP13 Phase 0a (2026-05-04): aux-head directional-accuracy reducer.
//
// Single-block tree-reduce kernel that scores the auxiliary next-bar
// regression head's per-bar prediction sign against the next-bar return
// label sign. Produces three batch-level fractions:
// out_3[0] = dir_acc — fraction of valid bars where pred_sign == label_sign
// out_3[1] = pos_pred_frac — fraction of valid bars where pred > 0
// out_3[2] = pos_label_frac — fraction of valid bars where label > 0
//
// "Valid" bars are those whose `next_bar_label != 0.0f` — a zero label is
// treated as "no signal to score against" (the producer of the label
// emits 0 when next-bar return rounds to flat) and excluded from the
// denominator entirely. When the entire batch is invalid (denom == 0),
// all three outputs are the random-baseline sentinel 0.5 (matches
// `DIR_ACC_EMA_SENTINEL` in `sp13_isv_slots.rs` per
// `pearl_first_observation_bootstrap` — the EMA's first observation
// replaces the sentinel directly).
//
// Sign convention for the prediction: `(p > 0.0f) ? 1 : 0`. Zero (and
// negative-zero) predict class 0 (negative). Tested by the
// `dir_acc_zero_pred_is_negative` GPU oracle.
//
// Reads regression-mode aux scalar in P0a (the existing aux_heads
// next-bar regression head writes one f32 per bar). Layer B refactors the
// aux head to a 2-class softmax classifier and updates this kernel to
// read `(softmax[1] > softmax[0])` instead — the producer/consumer
// contract on the rest of the pipeline (3-element output, sentinel
// semantics, valid-bar masking) is unchanged.
//
// Single block, BLOCK_SIZE=256. Four shared-memory int arrays
// (correct, pos_pred, pos_label, valid) all participate in one fused
// log2(BLOCK_SIZE) tree reduce — no atomicAdd per
// `feedback_no_atomicadd.md`. Pure GPU compute per
// `feedback_no_cpu_compute_strict.md`. Shared memory footprint:
// 4 × 256 × sizeof(int) = 4096 bytes.
#include <cuda_runtime.h>
#define BLOCK_SIZE 256
extern "C" __global__ void aux_dir_acc_reduce_kernel(
/* Per-bar aux-head prediction. P0a regression mode: one f32 per bar
* (the next-bar return regression scalar). Layer B switches the head
* to 2-class softmax and the launcher swaps the source pointer to
* `softmax[1] - softmax[0]` — same buffer shape, same kernel. */
const float* __restrict__ aux_pred,
/* Per-bar next-bar return label, sign-encoded. +1 / -1 for valid
* directional bars; 0 for "no signal" bars which are excluded from
* the denominator (see header comment). */
const float* __restrict__ next_bar_label,
/* Number of bars in the batch. The kernel strides each thread over
* `[0, batch_size)` so any batch_size up to ~2^31 is valid; in
* practice batches are O(1k-10k). */
int batch_size,
/* 3-element output buffer. Mapped-pinned in production (see the
* launcher in `gpu_dqn_trainer.rs`). The kernel writes
* out_3[0] = dir_acc
* out_3[1] = pos_pred_frac
* out_3[2] = pos_label_frac
* in thread 0 after the tree-reduce completes. */
float* __restrict__ out_3)
{
/* Single-block reducer — guard against accidental multi-block launch. */
if (blockIdx.x != 0) return;
const int tid = threadIdx.x;
const int bdim = blockDim.x;
/* Four int arrays packed back-to-back in dynamic shared memory.
* `extern __shared__ int shared[]` is sized at launch via
* `shared_mem_bytes = 4 × bdim × sizeof(int)` — the launcher MUST
* pass that exact value (the test scaffold and the production
* launcher both do). */
extern __shared__ int shared[];
int* sh_correct = &shared[0 * bdim];
int* sh_pos_pred = &shared[1 * bdim];
int* sh_pos_label = &shared[2 * bdim];
int* sh_valid = &shared[3 * bdim];
/* Per-thread strided accumulation. Each thread folds its slice of
* `[0, batch_size)` into four private int counters, then writes them
* into shared memory for the tree reduce. Bars whose label is zero
* (no directional signal to score against) are skipped entirely —
* `valid` does NOT increment, and `correct/pos_pred/pos_label` are
* not touched for those bars. */
int local_correct = 0;
int local_pos_pred = 0;
int local_pos_label = 0;
int local_valid = 0;
for (int i = tid; i < batch_size; i += bdim) {
const float p = aux_pred[i];
const float l = next_bar_label[i];
if (l == 0.0f) continue;
local_valid += 1;
const int pred_pos = (p > 0.0f) ? 1 : 0;
const int label_pos = (l > 0.0f) ? 1 : 0;
if (pred_pos == label_pos) local_correct += 1;
local_pos_pred += pred_pos;
local_pos_label += label_pos;
}
sh_correct[tid] = local_correct;
sh_pos_pred[tid] = local_pos_pred;
sh_pos_label[tid] = local_pos_label;
sh_valid[tid] = local_valid;
__syncthreads();
/* Standard log2(BLOCK_SIZE) tree reduction over all four arrays in
* lockstep. One `__syncthreads()` per pass keeps every thread's view
* of all four arrays consistent — splitting them into separate
* loops would cost 4× the syncs without any compute saving. */
for (int s = bdim / 2; s > 0; s >>= 1) {
if (tid < s) {
sh_correct[tid] += sh_correct[tid + s];
sh_pos_pred[tid] += sh_pos_pred[tid + s];
sh_pos_label[tid] += sh_pos_label[tid + s];
sh_valid[tid] += sh_valid[tid + s];
}
__syncthreads();
}
/* Thread 0 finalises. When the batch has no valid bars (empty batch
* or all-zero labels) the denom is 0; all three outputs fall back to
* 0.5 — the random-baseline sentinel matching DIR_ACC_EMA_SENTINEL
* in `sp13_isv_slots.rs`. The downstream EMA's first-observation
* replacement (Pearl A) consumes the sentinel naturally. */
if (tid == 0) {
const float denom = (float)sh_valid[0];
const float sentinel = 0.5f;
if (denom > 0.0f) {
out_3[0] = (float)sh_correct[0] / denom;
out_3[1] = (float)sh_pos_pred[0] / denom;
out_3[2] = (float)sh_pos_label[0] / denom;
} else {
out_3[0] = sentinel;
out_3[1] = sentinel;
out_3[2] = sentinel;
}
__threadfence_system();
}
}

View File

@@ -0,0 +1,111 @@
// crates/ml/src/cuda_pipeline/aux_pred_to_isv_tanh_kernel.cu
//
// SP13 Phase 0a P0a.T4 (2026-05-04): aux-head per-bar prediction →
// ISV[AUX_DIR_PREDICTION_INDEX=375] tanh-bounded scalar producer.
//
// Reads the aux next-bar regression head's `aux_pred [B]` tile (the
// per-bar return-prediction scalar produced inside the captured forward
// graph) and writes `mean(tanh(aux_pred[i]))` ∈ [-1, +1] to the SHARED
// ISV scalar at slot 375. The tanh squash bounds the scalar so the
// downstream consumer (the direction Q-head input layer mirroring the
// SP13 spec's "feed aux prediction back into the policy" wiring) sees
// a well-conditioned signal rather than a raw regression scalar that
// could grow with `label_scale`.
//
// Why a batch mean instead of per-bar broadcast: ISV is a single
// `[ISV_TOTAL_DIM]` shared array (see `gpu_dqn_trainer.rs::isv_signals_pinned`
// allocation) — there is NO per-batch tile. Every per-step ISV producer
// reduces a per-bar tile to a single scalar before writing (cf.
// `h_s2_rms_ema_kernel.cu` reducing `save_h_s2 [B, SH2]` to a single
// `producer_step_scratch_buf` slot, or `aux_label_scale_ema_update`
// reducing `aux_nb_label_buf [B]` to one EMA slot). The mean of
// per-bar predictions is the natural batch-aggregate signal for a
// shared scalar — it sits at zero when the head has no consensus
// direction, drifts toward +1/-1 as the head develops conviction.
//
// Sentinel-friendly: per `pearl_first_observation_bootstrap`,
// `(prev == 0.0)` is the cold-start sentinel for slot 375 (constructor
// zero-init; no fold-reset entry — the slot is per-step overwritten so
// any non-zero produces a clean replacement on the first launch via
// the existing per-step write semantics). No additional bootstrap
// branch is needed here because the kernel ALWAYS overwrites the
// slot with the current step's batch mean — it's a per-step state,
// not an EMA, and the registry entry skips slot 375 explicitly per
// the SP13 P0a registry comments.
//
// Single block, BLOCK_SIZE=256, one shared-memory float array — no
// atomicAdd per `feedback_no_atomicadd.md`. Pure GPU compute per
// `feedback_no_cpu_compute_strict.md`. Stream-ordered with the
// producer that wrote `aux_pred`; same-stream barrier suffices
// (cf. `aux_heads_loss_ema_update`'s same-stream contract with
// `aux_next_bar_loss_reduce`).
//
// Cost per launch: 256 threads × ceil(B/256) strided tanh + sum
// passes, plus log2(256)=8 reduce steps + 1 global write. B is
// O(1k-10k) in production training; total ~1-2µs per step, well
// under the per-step ISV-producer budget.
#include <cuda_runtime.h>
#define BLOCK_SIZE 256
extern "C" __global__ void aux_pred_to_isv_tanh_kernel(
/* Per-bar aux next-bar regression scalar [B] — produced inside
* the captured forward graph by `aux_next_bar_forward`, valid
* after the launcher's same-stream sync against the producer. */
const float* __restrict__ aux_pred,
/* Number of bars in the batch. The strided loop covers any
* `batch_size > 0`; empty batch falls through to the sentinel
* write (see thread-0 finaliser). */
int batch_size,
/* ISV slot index for the SHARED scalar — `AUX_DIR_PREDICTION_INDEX = 375`
* in the SP13 layout. The kernel writes
* isv[isv_slot_offset] = mean(tanh(aux_pred[i]))
* after the tree-reduce, with `__threadfence_system()` to make the
* write visible to subsequent same-stream consumers. */
int isv_slot_offset,
/* ISV array (whole bus). The kernel only touches slot
* `isv_slot_offset`; no other slots are read or written. */
float* __restrict__ isv)
{
/* Single-block reducer — guard against accidental multi-block launch. */
if (blockIdx.x != 0) return;
extern __shared__ float sh_sum[];
const int tid = threadIdx.x;
const int bdim = blockDim.x;
/* Per-thread strided accumulation: each thread folds tanh(aux_pred[i])
* over its slice of `[0, batch_size)`. tanh squashes any unbounded
* regression scalar into [-1, +1] before the reduce so the batch
* mean is bounded by construction (the pre-squash mean could grow
* unbounded with `label_scale` and contaminate downstream
* consumers). */
float local_sum = 0.0f;
for (int i = tid; i < batch_size; i += bdim) {
local_sum += tanhf(aux_pred[i]);
}
sh_sum[tid] = local_sum;
__syncthreads();
/* Standard log2(BLOCK_SIZE) tree reduction. */
for (int s = bdim / 2; s > 0; s >>= 1) {
if (tid < s) {
sh_sum[tid] += sh_sum[tid + s];
}
__syncthreads();
}
/* Thread 0 writes the batch mean. Empty-batch path writes 0.0
* (matches the constructor-zero sentinel for slot 375 — the
* downstream Q-head consumer treats 0.0 as "no aux signal yet"
* via the same logic that handles the cold-start step before
* the first forward pass). */
if (tid == 0) {
const float mean = (batch_size > 0)
? (sh_sum[0] / (float)batch_size)
: 0.0f;
isv[isv_slot_offset] = mean;
__threadfence_system();
}
}

View File

@@ -2768,6 +2768,18 @@ extern "C" __global__ void experience_env_step(
* `exiting_trade=1` upstream (line ~2371), so this branch fires
* for both — the segregation matters only for component attribution. */
float base_reward = 2.0f * vol_normalized_return;
/* SP13 v3 P0a.T3 (2026-05-04): per-bar Hold cost subtraction at the
* voluntary segment_complete branch. Goes BEFORE the SP12 asymmetric
* cap so the cost participates in the bounded clamp — keeps total
* reward in the same [-10, +5] range and avoids a long Hold run
* silently saturating the lower cap. Hold action rarely coincides
* with segment_complete (Hold keeps current position, doesn't close
* trades) but trail-fire can force-exit while the policy picks Hold;
* this branch covers that edge. Per-bar (non-segment_complete)
* Hold-cost subtraction is in the per-bar branches below. */
if (dir_idx == DIR_HOLD && isv_signals_ptr != NULL) {
base_reward -= isv_signals_ptr[ISV_HOLD_COST_IDX];
}
/* SP12 v3 fix (2026-05-04): asymmetric bounded cap (loss aversion).
* SP11 (commit 35db31089) made the cap symmetric ±10 to fix slot-63
* PopArt EMA inflation. That fix preserved stability but erased the
@@ -3148,6 +3160,20 @@ extern "C" __global__ void experience_env_step(
* (which is a separate concern; spec carve-out: "ONLY micro_reward
* and opp_cost are the per-bar shaping anti-patterns"). */
r_micro = 0.0f;
/* SP13 v3 P0a.T3 (2026-05-04): per-bar Hold cost subtraction.
* Hold action keeps the current position with zero PnL signal —
* pre-SP13 this was a free no-op the policy could pick indefinitely
* (CQL bias anchors toward minimum-variance Hold ~45% of bars across
* SP1-SP12). Hold-pricing makes Hold uneconomic when overused: the
* controller in `training_loop.rs` raises ISV[HOLD_COST_INDEX] when
* observed Hold-rate > target (default 0.20). The cost is bounded
* structurally (HOLD_COST_BASE × CEIL_RATIO = 0.005 max per bar),
* well below the SP12 asymmetric cap [-10, +5] — no per-bar clamp
* required. Spec §"Change 1: Price Hold (replaces v2's Eliminate
* Hold)". */
if (dir_idx == DIR_HOLD && isv_signals_ptr != NULL) {
r_micro -= isv_signals_ptr[ISV_HOLD_COST_IDX];
}
reward = r_micro;
micro_reward_per_sample[out_off] = r_micro;
reward_components_per_sample[out_off * 6 + 3] = r_micro;
@@ -3202,6 +3228,16 @@ extern "C" __global__ void experience_env_step(
* preserved: only the Flat opp_cost EMITTER is zeroed; the
* conviction Welford observer is independent. */
r_opp_cost = 0.0f;
/* SP13 v3 P0a.T3 (2026-05-04): per-bar Hold cost subtraction on
* the flat-non-event branch. Mirrors the positioned-branch
* subtraction above so a policy picking Hold while flat (i.e.
* "stay flat") pays the same per-bar cost as a policy picking
* Hold while positioned (i.e. "stay long/short"). The cost is
* structurally bounded by HOLD_COST_BASE × CEIL_RATIO = 0.005
* per bar, well below the SP12 asymmetric cap [-10, +5]. */
if (dir_idx == DIR_HOLD && isv_signals_ptr != NULL) {
r_opp_cost -= isv_signals_ptr[ISV_HOLD_COST_IDX];
}
reward = r_opp_cost;
reward_components_per_sample[out_off * 6 + 4] = r_opp_cost;
}

View File

@@ -588,6 +588,70 @@ static SP11_POPART_COMPONENT_EMA_CUBIN: &[u8] =
static SP11_NOVELTY_SIMHASH_PROJ_INIT_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/novelty_simhash_proj_init_kernel.cubin"));
/// SP13 v3 Phase 0a P0a.T3 (2026-05-04): Hold-rate observer reducer.
///
/// Single-block tree-reduce kernel that decodes per-bar packed factored
/// `batch_actions[i]` (`dir = action_idx / (NUM_MAGNITUDES * NUM_ORD * NUM_URG)`)
/// and writes the resulting fraction `count(Hold) / batch_size ∈ [0, 1]`
/// to a 1-element mapped-pinned scratch. The chained
/// `apply_fixed_alpha_ema_kernel` (α=0.05, sentinel 0.0) blends the
/// per-step observation into `ISV[HOLD_RATE_OBSERVED_EMA_INDEX=382]`;
/// the host-side Hold-cost controller in `training_loop.rs` reads the
/// EMA + the static target at `ISV[HOLD_RATE_TARGET_INDEX=381]` and
/// writes the priced Hold cost back to `ISV[HOLD_COST_INDEX=380]`. The
/// reward composition site in `experience_kernels.cu` then subtracts
/// the cost on every Hold-action bar — pricing the action so the policy
/// uses Hold deliberately rather than as a free CQL-bias-anchored
/// default.
///
/// Loaded by `GpuExperienceCollector` (per-step on the action-select
/// stream) — the per-step training-time observation is collector-owned
/// because `batch_actions` lives there. The trainer carries no copy
/// (avoids a dead `CudaFunction` field per `feedback_no_stubs.md`); the
/// `pub(crate)` visibility lets the collector's constructor reach the
/// cubin without re-declaring it. See `hold_rate_observer_kernel.cu`
/// for kernel contract details.
pub(crate) static SP13_HOLD_RATE_OBSERVER_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/hold_rate_observer_kernel.cubin"));
/// SP13 Phase 0a (2026-05-04): aux-head directional-accuracy reducer.
/// Single-block tree-reduce kernel that scores the aux next-bar
/// regression head's per-bar prediction sign against the next-bar return
/// label sign and writes (dir_acc, pos_pred_frac, pos_label_frac) into a
/// 3-element mapped-pinned buffer. Consumed by
/// `GpuDqnTrainer::launch_aux_dir_acc_reduce` (the per-step launcher
/// wired by P0a.T4 — this static include lands the cubin handle for the
/// constructor in P0a.T2). Produces directional-accuracy signal feeding
/// the SP13 EMAs at ISV[373..375) (short/long EMAs, sentinel 0.5 per
/// `pearl_first_observation_bootstrap`). See
/// `aux_dir_acc_reduce_kernel.cu` for kernel contract details.
static SP13_AUX_DIR_ACC_REDUCE_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/aux_dir_acc_reduce_kernel.cubin"));
/// SP13 Phase 0a P0a.T4 (2026-05-04): fixed-α EMA applicator. Sibling
/// of `apply_pearls_kernel` for cases where Pearls A+D's Wiener-optimal
/// blend would collapse two EMAs of the same signal to identical
/// values (defeating the SP13 stagnation detector that compares
/// slot 373's α=0.3 EMA against slot 374's α=0.05 EMA). Per-thread
/// first-observation-sentinel bootstrap + fixed-α blend; caller passes
/// the slot's registry sentinel (0.5 for dir-acc, 0.0 for hold-rate).
/// Consumed by `GpuDqnTrainer::launch_apply_fixed_alpha_ema`. See
/// `apply_fixed_alpha_ema_kernel.cu` for kernel contract details.
pub(crate) static SP13_APPLY_FIXED_ALPHA_EMA_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/apply_fixed_alpha_ema_kernel.cubin"));
/// SP13 Phase 0a P0a.T4 (2026-05-04): aux-head per-bar prediction →
/// ISV[AUX_DIR_PREDICTION_INDEX=375] tanh-bounded scalar producer.
/// Single-block tree-reduce reads the captured-graph `aux_nb_pred_buf [B]`
/// tile and writes `mean(tanh(aux_pred[i]))` ∈ [-1, +1] to the SHARED
/// ISV slot 375 (per-step overwrite — not an EMA). tanh squash bounds
/// the scalar so the downstream Q-head consumer sees a well-conditioned
/// signal regardless of `label_scale` magnitude. Consumed by
/// `GpuDqnTrainer::launch_aux_pred_to_isv_tanh`. See
/// `aux_pred_to_isv_tanh_kernel.cu` for kernel contract details.
static SP13_AUX_PRED_TO_ISV_TANH_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/aux_pred_to_isv_tanh_kernel.cubin"));
/// SP11 Fix 39 (2026-05-04, Task A2): SimHash novelty signal — lookup +
/// update kernels sharing one cubin. Lookup reads
/// `1/sqrt(1+count)` ∈ [0, 1] for each (state, action) bucket; update
@@ -983,7 +1047,7 @@ const ISV_NETWORK_DIM: usize = 23;
/// (shifted 112→116 in Plan 4 Task 6 Commit A).
/// Written by the constructor; checked at checkpoint load. Fail-fast only — no migration
/// path exists. See spec §4.A.2 and `LAYOUT_FINGERPRINT_CURRENT` for structural-hash rationale.
pub(crate) const ISV_TOTAL_DIM: usize = 367; // SP5 + Layer D D1+D2+D3 + SP7 + SP8 + SP9 + SP10 + SP11: 173 + 194 (164 SP5 slots @ 174..278/280..340, with 2-slot gap before cross-fold-persistent Kelly block; Layer D D1 PnL outputs at [286..290); Layer D D2 health composition outputs at [290..294); Layer D D3 training metrics EMA at [294..297); SP7 T1 loss-balance Wiener stats at [297..313); SP7 activation-flag fix per-(head,branch) flags at [313..321); SP8 Fix 36 train_active_frac canary @ [321..322) + LB_MAX_BUDGET per-(head,branch) at [322..330); SP9 Fix 37 Kelly warmup floor at [330..331) + Q_VAR_MAG_EMA at [331..332) + INTENT_EVAL_DIVERGENCE at [332..333) + 3 EMA targets at [333..336) + 3 eval_dist mag bins at [336..339); SP10 Fix 38 EVAL_THOMPSON_TEMP at [339..340); SP11 Fix 39 reward-subsystem controller at [340..367) — 6 component weights [340..346) + 4 controller scalar outputs [346..350) + 2 val-sharpe canaries [350..352) + 6 mag-ratio canaries [352..358) + saboteur engagement [358] + PnL magnitude EMA [359] + popart-component magnitude EMA [360, B1b fix-up] + per-component variance EMAs [361..367, B1b smoke-recovery z-score normalization])
pub(crate) const ISV_TOTAL_DIM: usize = 383; // SP5 + Layer D D1+D2+D3 + SP7 + SP8 + SP9 + SP10 + SP11 + SP13 + SP13 v3: 173 + 210 (164 SP5 slots @ 174..278/280..340, with 2-slot gap before cross-fold-persistent Kelly block; Layer D D1 PnL outputs at [286..290); Layer D D2 health composition outputs at [290..294); Layer D D3 training metrics EMA at [294..297); SP7 T1 loss-balance Wiener stats at [297..313); SP7 activation-flag fix per-(head,branch) flags at [313..321); SP8 Fix 36 train_active_frac canary @ [321..322) + LB_MAX_BUDGET per-(head,branch) at [322..330); SP9 Fix 37 Kelly warmup floor at [330..331) + Q_VAR_MAG_EMA at [331..332) + INTENT_EVAL_DIVERGENCE at [332..333) + 3 EMA targets at [333..336) + 3 eval_dist mag bins at [336..339); SP10 Fix 38 EVAL_THOMPSON_TEMP at [339..340); SP11 Fix 39 reward-subsystem controller at [340..367) — 6 component weights [340..346) + 4 controller scalar outputs [346..350) + 2 val-sharpe canaries [350..352) + 6 mag-ratio canaries [352..358) + saboteur engagement [358] + PnL magnitude EMA [359] + popart-component magnitude EMA [360, B1b fix-up] + per-component variance EMAs [361..367, B1b smoke-recovery z-score normalization]; SP13 directional-skill instrumentation at [372..380) — TARGET_DIR_ACC [372] + AUX_DIR_ACC_SHORT/LONG_EMA [373..375) + AUX_DIR_PREDICTION [375] + DIR_SKILL_BONUS_ALPHA/BETA [376..378) + LUCK_WIN_DISCOUNT [378] + SKILL_BONUS_CAP_RATIO [379]; SP13 v3 P0a.T3 Hold-pricing controller at [380..383) — HOLD_COST [380] + HOLD_RATE_TARGET [381] + HOLD_RATE_OBSERVED_EMA [382]; intentional 5-slot boundary gap at [367..372))
/// Legacy alias preserved for call sites that haven't been audited for the
/// network-vs-total split. New code should pick `ISV_NETWORK_DIM` (for weight
/// tensor sizing) or `ISV_TOTAL_DIM` (for the broadcast bus buffer).
@@ -2040,7 +2104,11 @@ const fn layout_fingerprint_seed() -> &'static [u8] {
SABOTEUR_ENGAGEMENT_RATE=358;PNL_REWARD_MAGNITUDE_EMA=359;\
POPART_COMPONENT_MAG_EMA=360;\
REWARD_COMPONENT_VAR_EMA_BASE=361;\
ISV_TOTAL_DIM=367;\
TARGET_DIR_ACC=372;AUX_DIR_ACC_SHORT_EMA=373;AUX_DIR_ACC_LONG_EMA=374;\
AUX_DIR_PREDICTION=375;DIR_SKILL_BONUS_ALPHA=376;DIR_SKILL_BONUS_BETA=377;\
LUCK_WIN_DISCOUNT=378;SKILL_BONUS_CAP_RATIO=379;\
HOLD_COST=380;HOLD_RATE_TARGET=381;HOLD_RATE_OBSERVED_EMA=382;\
ISV_TOTAL_DIM=383;\
PARAM_W_A_H_S1=0;PARAM_B_A_H_S1=1;PARAM_W_B_H_S1=2;PARAM_B_B_H_S1=3;\
PARAM_W_RESIDUAL_H_S1=4;PARAM_GAMMA_H_S1=5;PARAM_BETA_H_S1=6;\
PARAM_W_A_H_S2=7;PARAM_B_A_H_S2=8;PARAM_W_B_H_S2=9;PARAM_B_B_H_S2=10;\
@@ -4926,6 +4994,47 @@ pub struct GpuDqnTrainer {
/// unwired).
pub(crate) sp11_popart_component_dev_ptr: u64,
pub(crate) sp11_popart_component_len: usize,
/// SP13 Phase 0a (2026-05-04): aux-head directional-accuracy
/// reducer kernel. Single-block tree-reduce loaded from
/// `aux_dir_acc_reduce_kernel.cubin`. Reads the aux next-bar
/// regression-head prediction buffer + a per-bar sign-encoded
/// `next_bar_label` buffer and writes
/// `(dir_acc, pos_pred_frac, pos_label_frac)` to
/// `aux_dir_acc_buf` (mapped-pinned, 3 floats). Producer is wired
/// by P0a.T4; the kernel handle here lands the constructor edge
/// per `feedback_no_partial_refactor.md` (P0a is one atomic
/// commit). See the kernel source for the per-bar prediction sign
/// convention (`(p > 0.0f) ? 1 : 0`) and the all-zero-label
/// sentinel (0.5, matching `DIR_ACC_EMA_SENTINEL`).
aux_dir_acc_reduce: CudaFunction,
/// SP13 Phase 0a (2026-05-04): mapped-pinned 3-element output
/// buffer for `aux_dir_acc_reduce`. Layout `[dir_acc,
/// pos_pred_frac, pos_label_frac]` per the kernel contract. Per
/// `feedback_no_htod_htoh_only_mapped_pinned.md`: every CPU↔GPU
/// scalar read-back goes through `MappedF32Buffer` (host writes
/// the launcher reads via `read_all()`'s volatile read after stream
/// sync). Constructor-zero-initialised; the kernel overwrites all
/// three slots on every launch (or all three to the sentinel 0.5
/// when the batch is empty / all-zero-label).
pub(crate) aux_dir_acc_buf: super::mapped_pinned::MappedF32Buffer,
/// SP13 Phase 0a P0a.T4 (2026-05-04): fixed-α EMA applicator
/// kernel handle. Loaded from `apply_fixed_alpha_ema_kernel.cubin`.
/// Consumed by `launch_apply_fixed_alpha_ema` for the SP13 dir-acc
/// EMAs (slots 373/374 with α=0.3 / 0.05 and sentinel 0.5) and the
/// hold-rate EMA (slot 382 with α=0.05 and sentinel 0.0). Sibling
/// of `apply_pearls_ad_kernel`; see field declaration there for the
/// Wiener-optimal counterpart. Fixed-α form needed because Pearls
/// A+D collapse two EMAs of the same signal to identical values,
/// defeating the stagnation detector that compares slot 373 vs 374.
apply_fixed_alpha_ema_kernel: CudaFunction,
/// SP13 Phase 0a P0a.T4 (2026-05-04): aux-head per-bar prediction
/// → ISV[AUX_DIR_PREDICTION_INDEX=375] tanh-bounded scalar producer
/// kernel handle. Loaded from `aux_pred_to_isv_tanh_kernel.cubin`.
/// Single-block tree-reduce reads `aux_nb_pred_buf [B]` and writes
/// `mean(tanh(aux_pred[i]))` ∈ [-1, +1] to ISV[375]. Per-step state
/// (not an EMA — slot is overwritten each launch); no FoldReset
/// registry entry. Consumed by `launch_aux_pred_to_isv_tanh`.
aux_pred_to_isv_tanh_kernel: CudaFunction,
/// SP11 Fix 39 (2026-05-04, Task A2): reward-subsystem controller
/// kernel. Single-block, 10-thread producer reading 5 canary ISV slots
/// [350..360) and writing 10 floats to scratch[SCRATCH_SP11_CONTROLLER_BASE..+10).
@@ -13044,6 +13153,248 @@ impl GpuDqnTrainer {
Ok(())
}
/// SP13 Phase 0a (2026-05-04): launch the aux-head directional-
/// accuracy reducer.
///
/// Reads `aux_pred [B]` (the per-bar aux next-bar regression
/// prediction tile, P0a regression mode) and `next_bar_label [B]`
/// (per-bar sign-encoded label: +1 / 0 / -1) and writes
/// `(dir_acc, pos_pred_frac, pos_label_frac)` into the 3-element
/// output buffer at `out_3_dev`. Both predictions and labels live
/// on the GPU; the 3-element output is the trainer's mapped-pinned
/// `aux_dir_acc_buf` in production (the launcher is invoked with
/// `aux_dir_acc_buf.dev_ptr` from `training_loop.rs` after the
/// captured forward graph populates `aux_nb_pred_buf` /
/// `aux_nb_label_buf`). The launcher takes raw `u64` device
/// pointers per Decision D / `feedback_no_htod_htoh_only_mapped_pinned.md`
/// because the production source for `out_3_dev` is a
/// `MappedF32Buffer` (which exposes only `dev_ptr: u64`).
///
/// Single-block tree-reduce (BLOCK_SIZE=256) — four shared-memory
/// int arrays (correct/pos_pred/pos_label/valid) reduce in lockstep
/// per `feedback_no_atomicadd.md`. The launcher computes
/// `shared_mem_bytes = 4 × bdim × sizeof(i32)` to match the kernel's
/// `extern __shared__ int shared[]` declaration; passing a smaller
/// value would make the kernel read past the dynamic shared-memory
/// region and corrupt other data.
///
/// Per `feedback_cudarc_f64_f32_abi.md`: `batch_size` is `i32`
/// (matches the kernel signature exactly — no implicit cast).
pub(crate) fn launch_aux_dir_acc_reduce(
&self,
aux_pred_dev: u64,
next_bar_label_dev: u64,
batch_size: i32,
out_3_dev: u64,
) -> Result<(), MLError> {
let bdim: u32 = 256;
let cfg = LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (bdim, 1, 1),
shared_mem_bytes: 4 * bdim * std::mem::size_of::<i32>() as u32,
};
unsafe {
self.stream
.launch_builder(&self.aux_dir_acc_reduce)
.arg(&aux_pred_dev)
.arg(&next_bar_label_dev)
.arg(&batch_size)
.arg(&out_3_dev)
.launch(cfg)
.map_err(|e| MLError::ModelError(format!("sp13 aux_dir_acc_reduce launch: {e}")))?;
}
Ok(())
}
/// SP13 Phase 0a P0a.T4 (2026-05-04): launch the fixed-α EMA
/// applicator.
///
/// One launch updates `n` consecutive ISV slots starting at
/// `isv_offset`, blending each with the matching `sample[i]` at
/// fixed `alpha`. Caller passes the registry sentinel for the
/// destination slots (`pearl_first_observation_bootstrap`):
/// - 0.5 for the SP13 dir-acc EMAs at slots 373 / 374 (random-
/// guessing baseline; sentinel means "no observation yet")
/// - 0.0 for the hold-rate EMA at slot 382 (empty-batch fallback)
///
/// Sibling of `launch_apply_pearls` — needed because the Wiener-
/// optimal blend collapses two EMAs of the same signal to identical
/// values, defeating the SP13 stagnation detector at slot 374
/// (compares against slot 373's faster EMA). The fixed-α form
/// preserves the timescale separation that makes "improving vs
/// stalled" diagnosable.
///
/// Stream-ordered with the producer that wrote `sample_dev`. The
/// producer kernel MUST issue `__threadfence_system()` before
/// returning so this kernel's per-thread `sample[i]` read is
/// visible.
pub(crate) fn launch_apply_fixed_alpha_ema(
&self,
sample_dev: u64,
n: i32,
isv_offset: i32,
alpha: f32,
sentinel: f32,
) -> Result<(), MLError> {
debug_assert!(n >= 0,
"launch_apply_fixed_alpha_ema: n must be non-negative, got {n}");
if n == 0 {
return Ok(());
}
debug_assert!(self.isv_signals_dev_ptr != 0,
"launch_apply_fixed_alpha_ema: isv_signals_dev_ptr must be allocated by constructor");
// 1D thread grid over the slot count. SP13 P0a always launches
// with n=1 (single dir-acc scalar fed into both EMAs in
// lockstep, hold-rate scalar into slot 382), but the kernel
// generalises to n-slot blocks for future use without an
// ABI change.
let bdim: u32 = (n as u32).min(256).max(1);
let grid: u32 = ((n as u32) + bdim - 1) / bdim;
let cfg = LaunchConfig {
grid_dim: (grid, 1, 1),
block_dim: (bdim, 1, 1),
shared_mem_bytes: 0,
};
let isv_dev = self.isv_signals_dev_ptr;
unsafe {
self.stream
.launch_builder(&self.apply_fixed_alpha_ema_kernel)
.arg(&sample_dev)
.arg(&n)
.arg(&isv_offset)
.arg(&alpha)
.arg(&sentinel)
.arg(&isv_dev)
.launch(cfg)
.map_err(|e| MLError::ModelError(format!("sp13 apply_fixed_alpha_ema launch: {e}")))?;
}
Ok(())
}
/// SP13 Phase 0a P0a.T4 (2026-05-04): launch the aux-head
/// per-bar prediction → ISV[AUX_DIR_PREDICTION_INDEX=375] tanh-
/// bounded scalar producer.
///
/// Reads `aux_pred [B]` (production source: `aux_nb_pred_buf`,
/// populated by the captured forward graph's
/// `aux_next_bar_forward`) and writes `mean(tanh(aux_pred[i]))`
/// ∈ [-1, +1] to the SHARED ISV slot at `isv_slot_offset`. Per-step
/// state, not an EMA — slot is overwritten each launch (no
/// FoldReset registry entry, per the SP13 P0a registry comments).
///
/// Single-block tree-reduce (BLOCK_SIZE=256) — one shared-memory
/// float array reduces in lockstep per `feedback_no_atomicadd.md`.
/// `shared_mem_bytes = bdim × sizeof(f32)`.
///
/// Stream-ordered with the producer that wrote `aux_pred_dev`;
/// same-stream contract mirrors `launch_aux_heads_loss_ema`.
pub(crate) fn launch_aux_pred_to_isv_tanh(
&self,
aux_pred_dev: u64,
batch_size: i32,
isv_slot_offset: i32,
) -> Result<(), MLError> {
debug_assert!(self.isv_signals_dev_ptr != 0,
"launch_aux_pred_to_isv_tanh: isv_signals_dev_ptr must be allocated by constructor");
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::<f32>() as u32,
};
let isv_dev = self.isv_signals_dev_ptr;
unsafe {
self.stream
.launch_builder(&self.aux_pred_to_isv_tanh_kernel)
.arg(&aux_pred_dev)
.arg(&batch_size)
.arg(&isv_slot_offset)
.arg(&isv_dev)
.launch(cfg)
.map_err(|e| MLError::ModelError(format!("sp13 aux_pred_to_isv_tanh launch: {e}")))?;
}
Ok(())
}
/// SP13 Phase 0a P0a.T4 (2026-05-04): orchestrator for the per-step
/// aux directional-accuracy producer chain.
///
/// Single-call API for `training_loop.rs` to fire the SP13 P0a
/// per-step metrics:
/// 1. `launch_aux_dir_acc_reduce` — reduce aux_nb_pred_buf +
/// aux_nb_label_buf into `aux_dir_acc_buf [3]`
/// (dir_acc, pos_pred_frac, pos_label_frac).
/// 2. `launch_apply_fixed_alpha_ema` — short EMA (α=0.3) into
/// ISV[AUX_DIR_ACC_SHORT_EMA_INDEX=373], sentinel 0.5.
/// 3. `launch_apply_fixed_alpha_ema` — slow EMA (α=0.05) into
/// ISV[AUX_DIR_ACC_LONG_EMA_INDEX=374], sentinel 0.5.
/// 4. `launch_aux_pred_to_isv_tanh` — mean(tanh(aux_pred)) →
/// ISV[AUX_DIR_PREDICTION_INDEX=375].
///
/// All launches are stream-ordered; the producer's
/// `__threadfence_system()` orders against the consumer reads on
/// the same stream so no host sync is needed between steps.
/// Caller invokes once per training step BEFORE the HEALTH_DIAG
/// snapshot reads slot 373/374/375 (matches the post-cascade-fix
/// invariant from commit `a5f23b28f`).
///
/// Pre-condition: the captured forward graph has run this step,
/// so `aux_nb_pred_buf` and `aux_nb_label_buf` are populated.
/// Producer-only — no consumer kernel reads slots 373/374/375 in
/// this commit; Phase 0b wires the controller and the Q-head
/// input layer that reads slot 375.
pub fn launch_sp13_aux_dir_metrics(
&self,
) -> Result<(), MLError> {
use crate::cuda_pipeline::sp13_isv_slots::{
AUX_DIR_ACC_SHORT_EMA_INDEX, AUX_DIR_ACC_LONG_EMA_INDEX,
AUX_DIR_PREDICTION_INDEX,
DIR_ACC_EMA_SENTINEL,
};
let aux_pred_dev = self.aux_nb_pred_buf.raw_ptr();
let aux_label_dev = self.aux_nb_label_buf.raw_ptr();
let out_3_dev = self.aux_dir_acc_buf.dev_ptr;
let batch_size_i32 = self.config.batch_size as i32;
// 1. Reduce → mapped-pinned aux_dir_acc_buf [3]
self.launch_aux_dir_acc_reduce(
aux_pred_dev, aux_label_dev, batch_size_i32, out_3_dev,
)?;
// 2. Short EMA (α=0.3) — fast tracker for aux-w controller deficit term.
// n=1: only out_3[0] = dir_acc feeds the EMA; pos_pred/pos_label slots
// are diagnostic-only and don't have ISV destinations.
self.launch_apply_fixed_alpha_ema(
out_3_dev,
1,
AUX_DIR_ACC_SHORT_EMA_INDEX as i32,
0.3,
DIR_ACC_EMA_SENTINEL,
)?;
// 3. Slow EMA (α=0.05) — stagnation detector comparator.
self.launch_apply_fixed_alpha_ema(
out_3_dev,
1,
AUX_DIR_ACC_LONG_EMA_INDEX as i32,
0.05,
DIR_ACC_EMA_SENTINEL,
)?;
// 4. mean(tanh(aux_pred)) → ISV[375]. SHARED scalar (per-step
// overwrite, no EMA — see kernel header for layout rationale).
self.launch_aux_pred_to_isv_tanh(
aux_pred_dev,
batch_size_i32,
AUX_DIR_PREDICTION_INDEX as i32,
)?;
Ok(())
}
/// SP11 Fix 39 (2026-05-04, Task A2): launch the reward-subsystem
/// controller producer + chained Pearls A+D output smoothing.
///
@@ -16487,6 +16838,67 @@ impl GpuDqnTrainer {
module.load_function("popart_component_ema_kernel")
.map_err(|e| MLError::ModelError(format!("sp11 popart_component_ema load: {e}")))?
};
// SP13 Phase 0a (2026-05-04): aux-head directional-accuracy
// reducer kernel + 3-element mapped-pinned readback buffer.
// Producer reads the aux next-bar regression-head prediction
// tile + the per-bar sign-encoded next_bar_label tile and
// writes (dir_acc, pos_pred_frac, pos_label_frac) to the
// 3-float mapped-pinned buffer. P0a.T4 wires the per-step
// launch + ISV[373..375) EMA producer; this constructor edge
// is the cubin/kernel handle + buffer alloc — both atomic with
// the rest of the P0a chain per `feedback_no_partial_refactor.md`.
let aux_dir_acc_reduce = {
let module = stream.context()
.load_cubin(SP13_AUX_DIR_ACC_REDUCE_CUBIN.to_vec())
.map_err(|e| MLError::ModelError(format!("sp13 aux_dir_acc_reduce cubin load: {e}")))?;
module.load_function("aux_dir_acc_reduce_kernel")
.map_err(|e| MLError::ModelError(format!("sp13 aux_dir_acc_reduce load: {e}")))?
};
let aux_dir_acc_buf = unsafe {
super::mapped_pinned::MappedF32Buffer::new(3)
}.map_err(|e| MLError::ModelError(
format!("SP13 aux_dir_acc_buf alloc (3 f32): {e}")
))?;
// SP13 v3 P0a.T3 (2026-05-04): the per-step Hold-rate observer
// chain lives on the `GpuExperienceCollector` stream — the
// observer reads `batch_actions [N]` (collector-owned) and the
// chained fixed-α EMA writes ISV[382]. The collector loads its
// own copy of the cubins from `SP13_HOLD_RATE_OBSERVER_CUBIN` and
// `SP13_APPLY_FIXED_ALPHA_EMA_CUBIN`, so the trainer carries no
// hold-rate-specific kernel handle or scratch buffer (avoids a
// dead `CudaFunction` field per `feedback_no_stubs.md`). The
// trainer's host-side controller in `training_loop.rs:3604`
// reads the slot-382 EMA + slot-381 target via
// `read_isv_signal_at` at the per-epoch metrics block and
// writes the priced cost to ISV[HOLD_COST_INDEX=380]; the
// reward-composition site in `experience_kernels.cu` consumes
// ISV[380] from the same shared `isv_signals_dev_ptr`. The
// ISV[380] / ISV[381] / ISV[382] sentinels are seeded by
// `state_reset_registry`'s SP13 entries and `seed_static_signals`
// (Invariant-1 anchors at ISV[380, 381]).
// SP13 Phase 0a P0a.T4 (2026-05-04): fixed-α EMA applicator
// kernel handle + aux-pred → ISV[375] tanh producer handle.
// Both load from their cubins and stay live for the trainer's
// lifetime. P0a.T4 wires the per-step launches in
// `launch_sp13_aux_dir_metrics` and the hold-rate observer
// chain in `gpu_experience_collector.rs`; this constructor edge
// is the cubin/kernel handle, all-atomic with the rest of the
// P0a chain per `feedback_no_partial_refactor.md`.
let apply_fixed_alpha_ema_kernel = {
let module = stream.context()
.load_cubin(SP13_APPLY_FIXED_ALPHA_EMA_CUBIN.to_vec())
.map_err(|e| MLError::ModelError(format!("sp13 apply_fixed_alpha_ema cubin load: {e}")))?;
module.load_function("apply_fixed_alpha_ema_kernel")
.map_err(|e| MLError::ModelError(format!("sp13 apply_fixed_alpha_ema_kernel load: {e}")))?
};
let aux_pred_to_isv_tanh_kernel = {
let module = stream.context()
.load_cubin(SP13_AUX_PRED_TO_ISV_TANH_CUBIN.to_vec())
.map_err(|e| MLError::ModelError(format!("sp13 aux_pred_to_isv_tanh cubin load: {e}")))?;
module.load_function("aux_pred_to_isv_tanh_kernel")
.map_err(|e| MLError::ModelError(format!("sp13 aux_pred_to_isv_tanh_kernel load: {e}")))?
};
// SP11 Fix 39 B1b fix-up (2026-05-04): allocate trainer-side
// popart-component-per-sample mapped-pinned placeholder buffer.
// The canonical per-bar buffer is owned by the experience
@@ -18766,6 +19178,39 @@ impl GpuDqnTrainer {
*sig_ptr.add(KELLY_DIVERGENCE_TARGET_INDEX) = 2.0_f32;
*sig_ptr.add(KELLY_TEMPORAL_TARGET_INDEX) = 5.0_f32;
// SP13 directional-skill Invariant-1 anchors. Constants, not
// stateful EMAs — must never reach 0 sentinel between folds
// (FoldReset rewrites them in `reset_named_state`).
use crate::cuda_pipeline::sp5_isv_slots::{
TARGET_DIR_ACC_INDEX, TARGET_DIR_ACC_DEFAULT,
DIR_SKILL_BONUS_ALPHA_INDEX, DIR_SKILL_BONUS_ALPHA_DEFAULT,
DIR_SKILL_BONUS_BETA_INDEX, DIR_SKILL_BONUS_BETA_DEFAULT,
LUCK_WIN_DISCOUNT_INDEX, LUCK_WIN_DISCOUNT_DEFAULT,
SKILL_BONUS_CAP_RATIO_INDEX, SKILL_BONUS_CAP_RATIO_DEFAULT,
};
*sig_ptr.add(TARGET_DIR_ACC_INDEX) = TARGET_DIR_ACC_DEFAULT;
*sig_ptr.add(DIR_SKILL_BONUS_ALPHA_INDEX) = DIR_SKILL_BONUS_ALPHA_DEFAULT;
*sig_ptr.add(DIR_SKILL_BONUS_BETA_INDEX) = DIR_SKILL_BONUS_BETA_DEFAULT;
*sig_ptr.add(LUCK_WIN_DISCOUNT_INDEX) = LUCK_WIN_DISCOUNT_DEFAULT;
*sig_ptr.add(SKILL_BONUS_CAP_RATIO_INDEX) = SKILL_BONUS_CAP_RATIO_DEFAULT;
// SP13 v3 P0a.T3 (2026-05-04): Hold-pricing controller anchors.
// HOLD_COST_INDEX is rewritten every step by the host-side
// controller in `training_loop.rs`; the constructor write
// seeds it with the baseline cost so the first
// `experience_env_step` (before the controller fires) sees
// a meaningful value rather than 0 (which would silently
// disable Hold-pricing on the first batch). HOLD_RATE_TARGET
// is a static MFT default 0.20 — never reaches sentinel 0
// between folds. The observed-rate EMA at slot 382 is
// FoldReset (sentinel 0.0) and registered separately.
use crate::cuda_pipeline::sp5_isv_slots::{
HOLD_COST_INDEX, HOLD_COST_BASE,
HOLD_RATE_TARGET_INDEX, HOLD_RATE_TARGET_DEFAULT,
};
*sig_ptr.add(HOLD_COST_INDEX) = HOLD_COST_BASE;
*sig_ptr.add(HOLD_RATE_TARGET_INDEX) = HOLD_RATE_TARGET_DEFAULT;
// Layout fingerprint (ISV[58..60)). Compile-time structural hash of
// the slot layout; checkpoint load fails-fast on mismatch.
// Stored as a u64 split across two f32 lanes using raw bit-cast so
@@ -19913,6 +20358,20 @@ impl GpuDqnTrainer {
sp11_popart_component_ema_kernel,
sp11_popart_component_dev_ptr: 0,
sp11_popart_component_len: 0,
// SP13 Phase 0a (2026-05-04): aux-head directional-accuracy
// reducer kernel + 3-element mapped-pinned readback buffer.
// Both fields atomic with the SP13 ISV slot constants and
// SP13 state-reset registry entries staged by P0a.T1; the
// per-step launcher is wired by P0a.T4.
aux_dir_acc_reduce,
aux_dir_acc_buf,
// SP13 Phase 0a P0a.T4 (2026-05-04): fixed-α EMA applicator +
// aux-pred → ISV[375] tanh producer kernel handles. Wired into
// `launch_sp13_aux_dir_metrics` (called per step from
// `training_loop.rs`) and the hold-rate observer chain in
// `gpu_experience_collector.rs` per-step action-select site.
apply_fixed_alpha_ema_kernel,
aux_pred_to_isv_tanh_kernel,
// SP11 Fix 39 (Task A2): controller + SimHash novelty kernels
// and their backing buffers. Projection matrix was populated
// on-device above by `launch_novelty_simhash_proj_init`; hash

View File

@@ -833,6 +833,34 @@ pub struct GpuExperienceCollector {
/// `feedback_no_cpu_compute_strict.md`.
apply_pearls_ad_kernel: CudaFunction,
/// SP13 v3 Phase 0a P0a.T4 (2026-05-04): Hold-rate observer kernel
/// handle on the collector's stream. Reads the per-bar PACKED
/// factored `batch_actions [B]` tile (written by
/// `experience_action_select` to `out_actions[i] = dir*M*O*U + ...`)
/// and writes the Hold-pick fraction into `hold_rate_buf [1]`.
/// Same cubin as the trainer's handle (`SP13_HOLD_RATE_OBSERVER_CUBIN`)
/// — loaded on this stream so the per-step launch chains directly
/// after `experience_action_select` without a cross-stream sync.
sp13_hold_rate_observer_kernel: CudaFunction,
/// SP13 Phase 0a P0a.T4 (2026-05-04): fixed-α EMA applicator kernel
/// handle on the collector's stream. Reads `hold_rate_buf [1]` and
/// blends into ISV[HOLD_RATE_OBSERVED_EMA_INDEX=382] with α=0.05
/// and sentinel 0.0 (matching the registry entry
/// `sp13_hold_rate_observed_ema`). Same cubin as the trainer's
/// `apply_fixed_alpha_ema_kernel` field — Pearls A+D would be the
/// wrong tool here because the spec needs the slow-EMA timescale
/// (α=0.05) for the host-side controller's gain math. Loaded on
/// this stream so the per-step EMA fires directly after the
/// observer launch with no cross-stream sync.
sp13_apply_fixed_alpha_ema_kernel: CudaFunction,
/// SP13 v3 Phase 0a P0a.T4 (2026-05-04): mapped-pinned 1-element
/// scratch for the Hold-rate observer's per-step output. Per
/// `feedback_no_htod_htoh_only_mapped_pinned.md`: kernel writes via
/// `dev_ptr` (with `__threadfence_system()`); the chained fixed-α
/// EMA reads via the same `dev_ptr`. Constructor-zero-initialised;
/// the kernel overwrites it on every launch.
sp13_hold_rate_buf: MappedF32Buffer,
/// B.2 Plan 3 Task 3: trade_attempt_rate_ema_update GPU kernel.
/// Single-block (1 thread) reduction + adaptive EMA of Flat→Positioned
/// transition rate into ISV[71]. Launched alongside reward_component_ema
@@ -1516,6 +1544,39 @@ impl GpuExperienceCollector {
.map_err(|e| MLError::ModelError(format!("apply_pearls_ad_kernel load: {e}")))?
};
// SP13 v3 Phase 0a P0a.T4 (2026-05-04): Hold-rate observer +
// fixed-α EMA applicator on the collector's stream. The observer
// chains directly after `experience_action_select` (which writes
// `batch_actions [B]` packed factored action_idx) so the kernels
// MUST live on the same stream — cross-stream sync would defeat
// the no-host-sync per-step contract. Same cubins as the trainer's
// handles; loaded twice (once per stream) per the cudarc Function/
// Module pattern. The 1-element mapped-pinned `sp13_hold_rate_buf`
// is owned by the collector because it's a per-step transient
// scratch and the EMA writeback is into the trainer's ISV (read
// via `isv_signals_dev_ptr` set after construction by
// `set_isv_signals_ptr`).
let sp13_hold_rate_observer_kernel = {
use super::gpu_dqn_trainer::SP13_HOLD_RATE_OBSERVER_CUBIN;
let m = stream.context()
.load_cubin(SP13_HOLD_RATE_OBSERVER_CUBIN.to_vec())
.map_err(|e| MLError::ModelError(format!("sp13 hold_rate_observer cubin (collector): {e}")))?;
m.load_function("hold_rate_observer_kernel")
.map_err(|e| MLError::ModelError(format!("sp13 hold_rate_observer_kernel load (collector): {e}")))?
};
let sp13_apply_fixed_alpha_ema_kernel = {
use super::gpu_dqn_trainer::SP13_APPLY_FIXED_ALPHA_EMA_CUBIN;
let m = stream.context()
.load_cubin(SP13_APPLY_FIXED_ALPHA_EMA_CUBIN.to_vec())
.map_err(|e| MLError::ModelError(format!("sp13 apply_fixed_alpha_ema cubin (collector): {e}")))?;
m.load_function("apply_fixed_alpha_ema_kernel")
.map_err(|e| MLError::ModelError(format!("sp13 apply_fixed_alpha_ema_kernel load (collector): {e}")))?
};
let sp13_hold_rate_buf = unsafe { MappedF32Buffer::new(1) }
.map_err(|e| MLError::ModelError(
format!("SP13 v3 sp13_hold_rate_buf alloc (1 f32, collector): {e}")
))?;
// B.2 Plan 3 Task 3: load trade_attempt_rate_ema_update kernel.
let trade_attempt_rate_ema_kernel = {
use super::gpu_dqn_trainer::TRADE_RATE_EMA_CUBIN;
@@ -1798,6 +1859,13 @@ impl GpuExperienceCollector {
bn_tanh_concat_fn,
reward_component_ema_kernel,
apply_pearls_ad_kernel,
// SP13 v3 P0a.T4 (2026-05-04): Hold-rate observer + fixed-α
// EMA applicator + 1-elem mapped-pinned scratch — chained
// after `experience_action_select` per step in
// `collect_experiences_gpu`.
sp13_hold_rate_observer_kernel,
sp13_apply_fixed_alpha_ema_kernel,
sp13_hold_rate_buf,
trade_attempt_rate_ema_kernel,
plan_threshold_update_kernel,
state_kl_kernel,
@@ -3992,6 +4060,79 @@ impl GpuExperienceCollector {
}
}
// ── 4c. SP13 v3 Phase 0a P0a.T4 (2026-05-04): Hold-rate
// observer + fixed-α EMA into ISV[HOLD_RATE_OBSERVED_EMA_INDEX=382].
//
// Per-step on the SAME stream as `experience_action_select` (and
// the optional expert override above) so the observer reads
// `batch_actions [N]` AFTER both producers have written. Single-
// block tree-reduce → 1-elem mapped-pinned `sp13_hold_rate_buf`,
// then a 1-thread fixed-α (α=0.05, sentinel 0.0) EMA blend into
// the trainer's ISV via `isv_signals_dev_ptr` (set after
// construction by `set_isv_signals_ptr`). Skip both launches
// when ISV is not yet wired (test scaffold path) — the EMA
// would have nowhere to write.
//
// The trainer's host-side controller in `training_loop.rs:3604`
// reads slot 382 + slot 381 at the per-epoch metrics block and
// writes the priced cost to ISV[HOLD_COST_INDEX=380]; the
// reward-composition site in `experience_kernels.cu` then
// subtracts the cost on every Hold-action bar — pricing the
// action so the policy uses Hold deliberately rather than as a
// free CQL-bias-anchored default.
if self.isv_signals_dev_ptr != 0 && !self.seed_phase_active_cache {
use crate::cuda_pipeline::sp13_isv_slots::HOLD_RATE_OBSERVED_EMA_INDEX;
let actions_dev = self.batch_actions.raw_ptr();
let out_dev = self.sp13_hold_rate_buf.dev_ptr;
let bdim: u32 = 256;
// Step 1: count(decoded_dir == DIR_HOLD) / N → sp13_hold_rate_buf[0].
unsafe {
self.stream
.launch_builder(&self.sp13_hold_rate_observer_kernel)
.arg(&actions_dev)
.arg(&n_i32)
.arg(&out_dev)
.launch(LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (bdim, 1, 1),
shared_mem_bytes: bdim * std::mem::size_of::<i32>() as u32,
})
.map_err(|e| MLError::ModelError(format!(
"sp13 v3 hold_rate_observer t={t}: {e}"
)))?;
}
// Step 2: fixed-α EMA into ISV[382]. n=1 (single scalar);
// sentinel 0.0 matches the registry entry for slot 382;
// α=0.05 matches the spec's slow-EMA timescale for the
// host-side Hold-cost controller.
let n_slots: i32 = 1;
let isv_off: i32 = HOLD_RATE_OBSERVED_EMA_INDEX as i32;
let alpha: f32 = 0.05;
let sentinel: f32 = 0.0;
let isv_dev = self.isv_signals_dev_ptr;
unsafe {
self.stream
.launch_builder(&self.sp13_apply_fixed_alpha_ema_kernel)
.arg(&out_dev)
.arg(&n_slots)
.arg(&isv_off)
.arg(&alpha)
.arg(&sentinel)
.arg(&isv_dev)
.launch(LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (1, 1, 1),
shared_mem_bytes: 0,
})
.map_err(|e| MLError::ModelError(format!(
"sp13 v3 hold_rate apply_fixed_alpha_ema t={t}: {e}"
)))?;
}
}
// ── 5. Environment step (reward v5: trade-aware hybrid) ──────
// max_pos already defined above (action_select block)
// D4/N4: adaptive counterfactual ratio — 0.5 at healthy, 0.8 at collapse.

View File

@@ -0,0 +1,116 @@
// crates/ml/src/cuda_pipeline/hold_rate_observer_kernel.cu
//
// SP13 Phase 0a v3 (2026-05-04): Hold-rate observer.
//
// Single-block tree-reduce kernel that counts how many bars in a batch
// picked the Hold direction action and writes the resulting fraction
// `count(Hold) / batch_size ∈ [0, 1]` to `out_1[0]`. Pearls A+D then
// smooth this per-step observation into
// `ISV[HOLD_RATE_OBSERVED_EMA_INDEX=382]`; the host-side controller in
// `training_loop.rs` reads the EMA + the static target at
// `ISV[HOLD_RATE_TARGET_INDEX=381]` and writes the priced Hold cost back
// to `ISV[HOLD_COST_INDEX=380]`. The reward-composition site in
// `experience_kernels.cu` then subtracts the cost on every Hold-action
// bar — pricing the action so the policy uses Hold deliberately rather
// than as a free CQL-bias-anchored default.
//
// P0a.T4 (2026-05-04): the kernel reads the production PACKED factored
// `batch_actions [B]` tile (`int* action_idx`) and decodes direction
// inline as `dir = action_idx / (NUM_MAGNITUDES * NUM_ORD * NUM_URG)`.
// The earlier T3-staged signature took an unpacked `actions_dir [B]`
// tile that production never materialises; `experience_action_select`
// (the upstream producer) writes only the packed factored form to
// `out_actions[i] = dir*b1*b2*b3 + mag*b2*b3 + ord*b3 + urg`. Per
// `feedback_no_partial_refactor.md`, every consumer of the packed
// contract migrates atomically — this kernel + the T3 oracle tests are
// updated in lockstep with the launcher. Bucket sizes
// (`NUM_MAGNITUDES`, `NUM_ORD`, `NUM_URG`) live in `state_layout.cuh`
// so the kernel doesn't need an extra launcher arg.
//
// Sign convention:
// - decoded direction `dir == DIR_HOLD` (=1) → contributes 1 to count
// - any other direction (Short=0 / Long=2 / Flat=3) → contributes 0
// - empty batch (`batch_size <= 0`) → out = 0.0 (Pearl A's first-
// observation replacement consumes 0.0 cleanly because the
// companion sentinel for slot 382 is also 0.0; see registry entry
// `sp13_hold_rate_observed_ema`).
//
// Single block, BLOCK_SIZE=256, one shared-memory int array — no
// atomicAdd per `feedback_no_atomicadd.md`. Pure GPU compute per
// `feedback_no_cpu_compute_strict.md`. Mirrors the `aux_dir_acc_reduce`
// kernel's reducer shape (T2, 2026-05-04) at smaller surface — this
// kernel only needs ONE counter (Hold-count), the dir-acc kernel needed
// four (correct/pos_pred/pos_label/valid).
//
// `state_layout.cuh` provides the `DIR_HOLD` constant (==1) shared by
// every kernel that includes the header — the same constant
// `experience_action_select` writes when the policy picks Hold and the
// reward-composition site reads to gate the per-bar Hold-cost subtraction.
#include <cuda_runtime.h>
#include "state_layout.cuh"
#define BLOCK_SIZE 256
extern "C" __global__ void hold_rate_observer_kernel(
/* Per-bar PACKED factored action tile [B], values in
* `[0, NUM_DIRECTIONS * NUM_MAGNITUDES * NUM_ORD * NUM_URG)` =
* `[0, 4 * 3 * 3 * 3) = [0, 108)` for the production branch sizes.
* Encoding: `action_idx = dir*M*O*U + mag*O*U + ord*U + urg` —
* matches `experience_action_select`'s `out_actions[i]` write at
* `experience_kernels.cu:1453`. The launcher passes the post-
* action-selection `batch_actions` buffer; the kernel strides each
* thread over `[0, batch_size)` so any batch_size up to ~2^31 is
* valid. */
const int* __restrict__ batch_actions,
/* Number of bars in the batch. */
int batch_size,
/* 1-element output buffer. Mapped-pinned in production (see the
* launcher in `gpu_dqn_trainer.rs`). The kernel writes
* out_1[0] = count(decoded_dir == DIR_HOLD) / batch_size
* in thread 0 after the tree-reduce completes. */
float* __restrict__ out_1)
{
/* Single-block reducer — guard against accidental multi-block launch. */
if (blockIdx.x != 0) return;
const int tid = threadIdx.x;
const int bdim = blockDim.x;
/* Dynamic shared memory: one int array of `bdim` slots. The launcher
* passes `shared_mem_bytes = bdim × sizeof(int)`. */
extern __shared__ int sh_hold[];
/* Per-thread strided accumulation over the batch. Decode the packed
* factored action_idx and check direction; the divisor matches the
* authoritative encoding in `experience_action_select`. */
const int dir_divisor = NUM_MAGNITUDES * NUM_ORD * NUM_URG;
int local_hold = 0;
for (int i = tid; i < batch_size; i += bdim) {
const int action_idx = batch_actions[i];
const int dir = action_idx / dir_divisor;
if (dir == DIR_HOLD) local_hold += 1;
}
sh_hold[tid] = local_hold;
__syncthreads();
/* Standard log2(BLOCK_SIZE) tree reduction. One `__syncthreads()` per
* pass keeps every thread's view consistent. */
for (int s = bdim / 2; s > 0; s >>= 1) {
if (tid < s) {
sh_hold[tid] += sh_hold[tid + s];
}
__syncthreads();
}
/* Thread 0 finalises. Empty batch → 0.0 sentinel matches the slot-382
* fold-reset sentinel (see `sp13_hold_rate_observed_ema` registry
* entry); Pearl A's first-observation replacement consumes 0.0 cleanly. */
if (tid == 0) {
out_1[0] = (batch_size > 0)
? ((float)sh_hold[0] / (float)batch_size)
: 0.0f;
__threadfence_system();
}
}

View File

@@ -61,6 +61,7 @@ pub mod meta_q_network;
pub mod sp4_isv_slots;
pub mod sp5_isv_slots;
pub mod sp11_isv_slots;
pub mod sp13_isv_slots;
pub use sp4_isv_slots::{
TARGET_Q_BOUND_INDEX, ATOM_POS_BOUND_BASE, WEIGHT_BOUND_BASE,
ADAM_M_BOUND_BASE, ADAM_V_BOUND_BASE, WD_RATE_BASE,

View File

@@ -0,0 +1,76 @@
//! SP13 — Redefine success for predictive skill ISV slot constants.
//!
//! Slots [372..383) populated by the SP13 chain:
//! - 372: directional accuracy target (static, default 0.55)
//! - 373: aux dir-acc fast EMA (per-fold, sentinel 0.5)
//! - 374: aux dir-acc slow EMA (per-fold, sentinel 0.5) — stagnation detector
//! - 375: aux head per-bar prediction (per-bar overwrite, [-1, +1])
//! - 376: dir-skill bonus α (per-fold, default 1.0) — magnitude on correct calls
//! - 377: dir-skill bonus β (per-fold, default 1.0) — penalty on wrong calls
//! - 378: lucky-win discount factor (per-fold, default 0.3)
//! - 379: skill-bonus cap ratio (per-fold, default 0.3) — bonus ≤ ratio × |α|
//! - 380: per-bar Hold cost (controller output, sentinel = HOLD_COST_BASE)
//! - 381: target Hold-pick rate (static, default 0.20 — MFT-ish)
//! - 382: observed Hold-pick rate EMA (per-fold, sentinel 0.0)
//!
//! Slots 367..372 are an intentional gap separating the SP11 controller block
//! (ends at 367) from this SP13 block. The gap matches the codebase pattern of
//! reserving range markers for future SP-block boundaries.
//!
//! v3 Hold-pricing (slots 380..383, 2026-05-04): replaces v2's atomic Hold
//! elimination after the T3 implementer's audit found `DirectionAction` doesn't
//! exist in the codebase (8-variant fused `ExposureLevel` with cross-crate
//! consumers). Instead of removing Hold, the v3 chain prices it via an ISV-
//! driven adaptive cost controller targeting a Hold-rate (~20% — MFT-ish
//! deliberate use). When the model abuses Hold (rate > target), cost rises
//! until Hold becomes uneconomic; when rate ≤ target, cost relaxes to baseline
//! infrastructure carry. Per `feedback_isv_for_adaptive_bounds`: base cost is
//! the only constant; rate target, observed rate, and cost output are ISV-
//! driven. Spec §"Change 1: Price Hold (replaces v2's Eliminate Hold)".
//!
//! Spec: docs/superpowers/specs/2026-05-04-sp13-redefine-success-for-predictive-skill.md
pub const TARGET_DIR_ACC_INDEX: usize = 372;
pub const AUX_DIR_ACC_SHORT_EMA_INDEX: usize = 373;
pub const AUX_DIR_ACC_LONG_EMA_INDEX: usize = 374;
pub const AUX_DIR_PREDICTION_INDEX: usize = 375;
pub const DIR_SKILL_BONUS_ALPHA_INDEX: usize = 376;
pub const DIR_SKILL_BONUS_BETA_INDEX: usize = 377;
pub const LUCK_WIN_DISCOUNT_INDEX: usize = 378;
pub const SKILL_BONUS_CAP_RATIO_INDEX: usize = 379;
// ── v3 Hold-pricing controller (slots 380..383, 2026-05-04) ──────────
pub const HOLD_COST_INDEX: usize = 380;
pub const HOLD_RATE_TARGET_INDEX: usize = 381;
pub const HOLD_RATE_OBSERVED_EMA_INDEX: usize = 382;
/// Static-init defaults written ONCE at trainer construction (and re-written
/// at fold boundary by the constructor path — these are Invariant-1 numerical
/// anchors, not stateful EMAs).
pub const TARGET_DIR_ACC_DEFAULT: f32 = 0.55;
pub const DIR_SKILL_BONUS_ALPHA_DEFAULT: f32 = 1.0;
pub const DIR_SKILL_BONUS_BETA_DEFAULT: f32 = 1.0;
pub const LUCK_WIN_DISCOUNT_DEFAULT: f32 = 0.3;
pub const SKILL_BONUS_CAP_RATIO_DEFAULT: f32 = 0.3;
/// v3 Hold-pricing controller anchors (Invariant-1 numerical constants per
/// `feedback_isv_for_adaptive_bounds.md`). The controller scales `HOLD_COST_BASE`
/// by `1 + GAIN × max(0, observed target)` clamped to
/// `[FLOOR_RATIO × BASE, CEIL_RATIO × BASE]`. Base cost is 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 the SP12 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
/// Sentinel for both dir-acc EMAs at fold boundary: 0.5 is the random-guessing
/// baseline for binary directional accuracy. First observation replaces this
/// directly per `pearl_first_observation_bootstrap` so the EMA tracks the new
/// fold's signal without bias from the previous fold's terminal accuracy.
pub const DIR_ACC_EMA_SENTINEL: f32 = 0.5;
pub const SP13_SLOT_BASE: usize = 372;
pub const SP13_SLOT_END: usize = 383;

View File

@@ -21,14 +21,18 @@
//! 340..367 SP11 (Fix 39) reward-subsystem controller (27 slots, fold-reset) — see sp11_isv_slots
//! [includes B1b fix-up slot 360 = POPART_COMPONENT_MAG_EMA_INDEX
//! and B1b smoke-recovery 6 var EMAs at 361..367 for z-score normalization]
//! 367..372 Intentional 5-slot boundary gap separating SP11 controller block from SP13
//! 372..380 SP13 (2026-05-04) directional-skill instrumentation (8 slots) — see sp13_isv_slots
//! 380..383 SP13 v3 (2026-05-04) Hold-pricing controller (3 slots) — see sp13_isv_slots
//!
//! Total: 191 new SP5 ISV slots (52 + 24 + 20 + 4 + 4 + 6 + 4 + 4 + 3 + 16 + 8 + 9 + 9 + 1 + 27).
//! Total: 202 new SP5 ISV slots (52 + 24 + 20 + 4 + 4 + 6 + 4 + 4 + 3 + 16 + 8 + 9 + 9 + 1 + 27 + 8 + 3).
// Re-export SP11 slot constants so existing consumers can import every
// Re-export SP11 + SP13 slot constants so existing consumers can import every
// slot constant from a single location (`crate::cuda_pipeline::sp5_isv_slots::*`).
// SP11 owns its file but participates in the SP5 linear-span window
// (SP5_SLOT_BASE..SP5_SLOT_END covers all SP5/SP7/SP8/SP9/SP10/SP11 slots).
// SP11 / SP13 own their files but participate in the SP5 linear-span window
// (SP5_SLOT_BASE..SP5_SLOT_END covers all SP5/SP7/SP8/SP9/SP10/SP11/SP13 slots).
pub use crate::cuda_pipeline::sp11_isv_slots::*;
pub use crate::cuda_pipeline::sp13_isv_slots::*;
pub const SP5_SLOT_BASE: usize = 174;
@@ -304,7 +308,27 @@ pub const EVAL_THOMPSON_TEMP_INDEX: usize = 339; // [1] eval Thompson se
// handled by the existing bulk memset of `wiener_state_buf` covered by the
// `sp4_wiener_state` registry entry's dispatch arm.
pub const SP5_SLOT_END: usize = 367;
// ── SP13 (2026-05-04): redefine success for predictive skill ──────────
//
// 8 new SP13 ISV slots at ISV[372..380) for the directional-accuracy
// instrumentation + reward-composition controllers. Slots 367..372 are an
// intentional 5-slot gap separating the SP11 controller block (ends at 367)
// from this SP13 block — a structural boundary marker, not allocated. Per
// `pearl_controller_anchors_isv_driven.md` + `feedback_isv_for_adaptive_bounds`:
// every adaptive bound lives on ISV; the static defaults at slots 372 and
// 376..379 are constructor-written Invariant-1 anchors (not stateful EMAs)
// and will be lifted to controllers in Layer C if Phase 0a confirms the
// hypothesis. All 8 slot constants live in `crate::cuda_pipeline::sp13_isv_slots`
// (re-exported above).
//
// SP13 v3 (P0a.T3, 2026-05-04): 3 additional slots at ISV[380..383) for the
// Hold-pricing adaptive controller — `HOLD_COST_INDEX=380` (controller output,
// per-bar Hold cost), `HOLD_RATE_TARGET_INDEX=381` (static MFT target 0.20),
// `HOLD_RATE_OBSERVED_EMA_INDEX=382` (per-fold-reset Hold-pick rate EMA from
// `hold_rate_observer_kernel`). Replaces v2's atomic Hold elimination after
// the T3 implementer audited and found `DirectionAction` doesn't exist — the
// 4-way action space stays; Hold becomes priced rather than removed.
pub const SP5_SLOT_END: usize = 383;
/// Wiener-buffer producer-count constant. Sizes `wiener_state_buf` via
/// `(SP4_PRODUCER_COUNT + SP5_PRODUCER_COUNT) * SP4_WIENER_FLOATS_PER_SLOT`.
@@ -402,9 +426,9 @@ pub const SP5_SLOT_END: usize = 367;
/// FoldReset (sentinel 0; Pearl A bootstrap on first observation).
/// Constants for the SP11 block live in `crate::cuda_pipeline::sp11_isv_slots`
/// and are re-exported above.
pub const SP5_PRODUCER_COUNT: usize = 193;
// linear span = SP5_SLOT_END - SP5_SLOT_BASE = 367 - 174 = 193 wiener triples
// unique-slot count = 191 (52 per-branch + 24 Adam + 20 IQN τ + 4 trail
pub const SP5_PRODUCER_COUNT: usize = 209;
// linear span = SP5_SLOT_END - SP5_SLOT_BASE = 383 - 174 = 209 wiener triples
// unique-slot count = 202 (52 per-branch + 24 Adam + 20 IQN τ + 4 trail
// + 4 num_atoms + 6 Kelly + 4 Layer D D1 PnL aggregation
// + 4 Layer D D2 health composition
// + 3 Layer D D3 training metrics EMA
@@ -415,7 +439,11 @@ pub const SP5_PRODUCER_COUNT: usize = 193;
// + 9 SP9 Kelly cold-start warmup floor + targets + eval_dist
// + 1 SP10 eval Thompson temperature
// + 27 SP11 reward-subsystem controller [includes B1b fix-up
// slot 360 + B1b smoke-recovery 6 per-component var EMAs 361..367])
// slot 360 + B1b smoke-recovery 6 per-component var EMAs 361..367]
// + 8 SP13 directional-skill instrumentation [372..380); slots
// 367..372 are an intentional 5-slot gap, reserved-unused
// in both ISV and the wiener buffer
// + 3 SP13 v3 Hold-pricing controller [380..383))
// ── Convenience accessors ────────────────────────────────────────────
#[inline] pub const fn atom_v_center(b: usize) -> usize { ATOM_V_CENTER_BASE + b }
@@ -480,7 +508,11 @@ pub const SP5_LAYOUT_FINGERPRINT_FRAGMENT: &str =
SABOTEUR_ENGAGEMENT_RATE=358;PNL_REWARD_MAGNITUDE_EMA=359;\
POPART_COMPONENT_MAG_EMA=360;\
REWARD_COMPONENT_VAR_EMA_BASE=361;\
ISV_TOTAL_DIM=367";
TARGET_DIR_ACC=372;AUX_DIR_ACC_SHORT_EMA=373;AUX_DIR_ACC_LONG_EMA=374;\
AUX_DIR_PREDICTION=375;DIR_SKILL_BONUS_ALPHA=376;DIR_SKILL_BONUS_BETA=377;\
LUCK_WIN_DISCOUNT=378;SKILL_BONUS_CAP_RATIO=379;\
HOLD_COST=380;HOLD_RATE_TARGET=381;HOLD_RATE_OBSERVED_EMA=382;\
ISV_TOTAL_DIM=383";
#[cfg(test)]
mod tests {
@@ -593,33 +625,53 @@ mod tests {
slots.insert(s);
}
// 1. Exactly 191 unique slots (164 pre-SP11 + 27 SP11).
assert_eq!(slots.len(), 191, "expected 191 unique slots, got {}", slots.len());
// SP13 (2026-05-04): include the 8 directional-skill slots + the
// 3 v3 Hold-pricing controller slots — `SP13_SLOT_BASE..SP13_SLOT_END`
// covers both blocks contiguously since v3 extended the upper end
// from 380 to 383.
for s in SP13_SLOT_BASE..SP13_SLOT_END {
slots.insert(s);
}
// 1. Exactly 202 unique slots (164 pre-SP11 + 27 SP11 + 8 SP13 + 3 SP13 v3).
assert_eq!(slots.len(), 202, "expected 202 unique slots, got {}", slots.len());
// 2. Min slot is SP5_SLOT_BASE = 174.
assert_eq!(*slots.iter().min().unwrap(), 174);
// 3. Max slot is SP5_SLOT_END - 1 = 366.
assert_eq!(*slots.iter().max().unwrap(), 366);
// 3. Max slot is SP5_SLOT_END - 1 = 382.
assert_eq!(*slots.iter().max().unwrap(), 382);
// 4. Intentional carve-out gap (278, 279) is absent.
assert!(!slots.contains(&278), "slot 278 must be absent (carve-out gap)");
assert!(!slots.contains(&279), "slot 279 must be absent (carve-out gap)");
// 5. Set equals {174..278} {280..367} — no holes (other than the
// carve-out gap 278..280), no overlaps. Layer D D1 extended the
// upper end 286 → 290; D2 extended it 290 → 294; D3 extends it
// 294 → 297; SP7 T1 extended it 297 → 313; SP7 activation-flag
// fix extends it 313 → 321; SP8 (Fix 36) extends it 321 → 330;
// SP9 (Fix 37) extends it 330 → 339; SP10 (Fix 38) extends it
// 339 → 340; SP11 (Fix 39) extends it 340 → 360; SP11 B1b fix-up
// 5. SP13 boundary gap (367..372) is absent — separates SP11's controller
// block from SP13's directional-skill block.
for s in 367..372 {
assert!(!slots.contains(&s),
"slot {} must be absent (SP11→SP13 boundary gap)", s);
}
// 6. Set equals {174..278} {280..367} {372..383} — no holes (other
// than the carve-out gap 278..280 and the SP11→SP13 boundary gap
// 367..372), no overlaps. Layer D D1 extended the upper end
// 286 → 290; D2 extended it 290 → 294; D3 extends it 294 → 297;
// SP7 T1 extended it 297 → 313; SP7 activation-flag fix extends
// it 313 → 321; SP8 (Fix 36) extends it 321 → 330; SP9 (Fix 37)
// extends it 330 → 339; SP10 (Fix 38) extends it 339 → 340;
// SP11 (Fix 39) extends it 340 → 360; SP11 B1b fix-up
// (2026-05-04) extends it 360 → 361 with POPART_COMPONENT_MAG_EMA_INDEX;
// SP11 B1b smoke-recovery (2026-05-04) extends it 361 → 367 with
// `REWARD_COMPONENT_VAR_EMA_BASE..+6` for the z-score normalised
// mag-ratio canary.
let expected: HashSet<usize> = (174..278).chain(280..367).collect();
// mag-ratio canary; SP13 (2026-05-04) appends 372 → 380 with
// 8 directional-skill slots after the boundary gap 367..372;
// SP13 v3 P0a.T3 (2026-05-04) appends 380 → 383 with the 3 Hold-
// pricing controller slots (HOLD_COST=380, HOLD_RATE_TARGET=381,
// HOLD_RATE_OBSERVED_EMA=382).
let expected: HashSet<usize> = (174..278).chain(280..367).chain(372..383).collect();
assert_eq!(slots, expected,
"slot set does not match expected {{174..278}} {{280..367}}");
"slot set does not match expected {{174..278}} {{280..367}} {{372..383}}");
}
#[test]
@@ -646,11 +698,11 @@ mod tests {
// is cross-fold-persistent — the contracts must remain disjoint).
assert!(PNL_TOTAL_INDEX > LOSS_RATE_SMOOTH_INDEX);
// SP5_SLOT_END must reflect the post-SP8 end-of-block.
assert_eq!(SP5_SLOT_END, 367);
assert_eq!(SP5_SLOT_END, 383);
// SP5_PRODUCER_COUNT is the wiener-buffer linear span (slot-range
// width including the 2-slot carve-out gap), NOT the unique-slot
// count. See SP5_PRODUCER_COUNT docstring for the rationale.
assert_eq!(SP5_PRODUCER_COUNT, 193);
assert_eq!(SP5_PRODUCER_COUNT, 209);
assert_eq!(SP5_PRODUCER_COUNT, SP5_SLOT_END - SP5_SLOT_BASE);
}
@@ -669,7 +721,7 @@ mod tests {
// 4-slot block is internally contiguous.
assert_eq!(GRAD_NORM_NORM_INDEX - HEALTH_SCORE_INDEX, 3);
// SP5_SLOT_END must reflect the post-SP8 end-of-block.
assert_eq!(SP5_SLOT_END, 367);
assert_eq!(SP5_SLOT_END, 383);
// SP5_PRODUCER_COUNT linear-span check matches the new end.
assert_eq!(SP5_PRODUCER_COUNT, SP5_SLOT_END - SP5_SLOT_BASE);
}
@@ -690,12 +742,12 @@ mod tests {
assert_eq!(MAX_DD_EMA_INDEX - TRAINING_SHARPE_EMA_INDEX, 1);
assert_eq!(LOW_DD_RATIO_INDEX - MAX_DD_EMA_INDEX, 1);
// SP5_SLOT_END must reflect the post-SP8 end-of-block.
assert_eq!(SP5_SLOT_END, 367);
assert_eq!(SP5_SLOT_END, 383);
// SP5_PRODUCER_COUNT linear-span check matches the new end. The
// wiener buffer must cover the entire linear span — including the
// 2-slot carve-out gap (278..280) and the Pearl 6 reserved-but-
// unused 6-float block (slots 280..286 don't call apply_pearls).
assert_eq!(SP5_PRODUCER_COUNT, 193);
assert_eq!(SP5_PRODUCER_COUNT, 209);
assert_eq!(SP5_PRODUCER_COUNT, SP5_SLOT_END - SP5_SLOT_BASE);
}
@@ -715,7 +767,7 @@ mod tests {
assert_eq!(lb_c51_active(b), LB_C51_ACTIVE_BASE + b);
}
// SP5_SLOT_END / SP5_PRODUCER_COUNT cover the new range.
assert_eq!(SP5_SLOT_END, 367);
assert_eq!(SP5_SLOT_END, 383);
assert_eq!(SP5_PRODUCER_COUNT, SP5_SLOT_END - SP5_SLOT_BASE);
}
@@ -735,7 +787,7 @@ mod tests {
assert_eq!(lb_max_budget_c51(b), LB_MAX_BUDGET_C51_BASE + b);
}
// Layout: 1 + 4 + 4 = 9 contiguous slots (321..330).
assert_eq!(SP5_SLOT_END, 367);
assert_eq!(SP5_SLOT_END, 383);
assert_eq!(SP5_PRODUCER_COUNT, SP5_SLOT_END - SP5_SLOT_BASE);
}
@@ -768,7 +820,7 @@ mod tests {
// Strictly above the SP8 MAX_BUDGET block.
assert!(KELLY_WARMUP_FLOOR_INDEX > LB_MAX_BUDGET_C51_BASE + 3);
// Block end at ISV[340) post-SP10 (Fix 38).
assert_eq!(SP5_SLOT_END, 367);
assert_eq!(SP5_SLOT_END, 383);
// Linear span matches producer count (no gaps in SP9 block).
assert_eq!(SP5_PRODUCER_COUNT, SP5_SLOT_END - SP5_SLOT_BASE);
}
@@ -782,9 +834,9 @@ mod tests {
// Strictly above the SP9 eval_dist block (last entry at 338).
assert!(EVAL_THOMPSON_TEMP_INDEX > EVAL_DIST_F_INDEX);
// SP5_SLOT_END reflects the post-SP10 end-of-block.
assert_eq!(SP5_SLOT_END, 367);
assert_eq!(SP5_SLOT_END, 383);
// Linear span matches producer count (single new slot at top).
assert_eq!(SP5_PRODUCER_COUNT, SP5_SLOT_END - SP5_SLOT_BASE);
assert_eq!(SP5_PRODUCER_COUNT, 193);
assert_eq!(SP5_PRODUCER_COUNT, 209);
}
}

View File

@@ -135,6 +135,22 @@
#define MAG_FULL 2 // 1.00× max_position
#define NUM_MAGNITUDES 3
// ────────────────────────────────────────────────────────────────────────────
// Order-type and urgency branch sizes. Production default in
// `ml-dqn::dqn::DQNConfig` is 3 each (`num_order_types = 3`,
// `num_urgency_levels = 3`); the runtime config has never been tuned away
// from these and the kernels that consume packed factored actions assume
// the same value tile-wide. SP13 P0a.T4 (2026-05-04) lifts these into
// `state_layout.cuh` so kernels that need to decode the packed
// `action_idx = dir*M*O*U + mag*O*U + ord*U + urg` (e.g.
// `hold_rate_observer`) can compute the divisor without taking new
// launcher args. If runtime config ever changes, every consumer of the
// packed encoding must migrate atomically per
// `feedback_no_partial_refactor.md`.
// ────────────────────────────────────────────────────────────────────────────
#define NUM_ORD 3
#define NUM_URG 3
// ────────────────────────────────────────────────────────────────────────────
// ISV bus slot indices referenced by kernels that receive the ISV pointer.
// Kept in state_layout.cuh because both experience_kernels.cu and
@@ -152,6 +168,30 @@
#define ISV_SEED_FRAC_EMA_IDX 84 // == SEED_FRAC_EMA_INDEX — adaptive EMA of (1 - done/target) ∈ [0, 1] (Plan 3 Task 8 B.3; consumed by Task 9 CQL ramp)
#define ISV_EVAL_THOMPSON_TEMP_IDX 339 // == EVAL_THOMPSON_TEMP_INDEX — eval Thompson selector temperature (SP10 / Fix 38 2026-05-03; ISV-driven temperature blend on direction-branch Thompson sample)
// ────────────────────────────────────────────────────────────────────────────
// === SP13 SLOT INDICES === (2026-05-04, redefine success for predictive skill)
// Mirror of crates/ml/src/cuda_pipeline/sp13_isv_slots.rs. Slots [372..383)
// at the top of the SP5 ISV linear span; [367..372) is an intentional 5-slot
// boundary gap separating the SP11 controller block from this SP13 block.
// Slots 380..383 are the v3 P0a.T3 (2026-05-04) Hold-pricing controller
// (replaces v2's atomic Hold elimination — preserves the 4-way action space,
// prices Hold instead of removing it).
// Spec: docs/superpowers/specs/2026-05-04-sp13-redefine-success-for-predictive-skill.md
// ────────────────────────────────────────────────────────────────────────────
#define ISV_TARGET_DIR_ACC_IDX 372 // directional accuracy target (static, default 0.55)
#define ISV_AUX_DIR_ACC_SHORT_EMA_IDX 373 // aux dir-acc fast EMA (per-fold, sentinel 0.5)
#define ISV_AUX_DIR_ACC_LONG_EMA_IDX 374 // aux dir-acc slow EMA (per-fold, sentinel 0.5) — stagnation detector
#define ISV_AUX_DIR_PREDICTION_IDX 375 // aux head per-bar prediction (per-bar overwrite, [-1, +1])
#define ISV_DIR_SKILL_BONUS_ALPHA_IDX 376 // dir-skill bonus α (per-fold, default 1.0) — magnitude on correct calls
#define ISV_DIR_SKILL_BONUS_BETA_IDX 377 // dir-skill bonus β (per-fold, default 1.0) — penalty on wrong calls
#define ISV_LUCK_WIN_DISCOUNT_IDX 378 // lucky-win discount factor (per-fold, default 0.3)
#define ISV_SKILL_BONUS_CAP_RATIO_IDX 379 // skill-bonus cap ratio (per-fold, default 0.3) — bonus ≤ ratio × |α|
#define ISV_HOLD_COST_IDX 380 // SP13 v3 per-bar Hold cost (controller output, sentinel = HOLD_COST_BASE=0.001)
#define ISV_HOLD_RATE_TARGET_IDX 381 // SP13 v3 target Hold-pick rate (static, default 0.20)
#define ISV_HOLD_RATE_OBSERVED_EMA_IDX 382 // SP13 v3 observed Hold-pick rate EMA (per-fold, sentinel 0.0)
#define ISV_TOTAL_DIM 383
// ────────────────────────────────────────────────────────────────────────────
// Feature-group ranges — Plan 4 Task 1A (E.1 VSN prerequisite).
// Referenced by: VSN feature selection (E.1), attention diagnostics (E.5),

View File

@@ -905,6 +905,51 @@ impl StateResetRegistry {
category: ResetCategory::FoldReset,
description: "ISV[REWARD_COMPONENT_VAR_EMA_BASE=361..367) — SP11 Fix 39 B1b smoke-recovery (2026-05-04, spec §4 amendment 'Why z-score' lines 564-619) per-reward-component variance EMAs (6 contiguous slots: popart at 361, then cf 362, trail 363, micro 364, opp_cost 365, bonus 366). Slot 361 is produced alongside the popart-component magnitude EMA by the extended `popart_component_ema_kernel` via single-pass two-tree-reduce Welford (mean → var); slots 362..367 are produced alongside the non-popart magnitudes by the extended `reward_component_ema_kernel` via per-thread Welford (component 0/popart variance is split off because slot 63 is the pre-SP11 total-reward magnitude EMA, not popart-component magnitude — see slot-360 fix-up rationale). All 6 variances are smoothed by Pearls A+D via chained `apply_pearls_ad_kernel` (n_slots=1 for popart variance at slot 361, n_slots=5 for the contiguous block at slots 362..367). Consumed by `reward_component_mag_ratio_compute_kernel` for z-score normalisation: `z[c] = mag[c] / max(sqrt(var[c]), EPS_DIV)` then `ratio[c] = z[c] / sum_z`. Replaces the linear `winner_weight = mag_ratio` formula that amplified popart's intrinsic O(100) magnitude over the other components' O(0.1-2) — w_pop saturated to MAX_WEIGHT, curiosity_b exploded, sharpe collapsed in B1b smoke `smoke-test-6wd2c` on commit `61b2fa962`. The single registry entry covers the whole 6-slot block — the dispatch arm zeroes all 6 with one loop, matching the `sp11_reward_component_mag_ratios` block-reset pattern. FoldReset sentinel 0; Pearl A bootstraps the variance from the first observation alongside its paired magnitude.",
},
// ── SP13 (2026-05-04): redefine success for predictive skill ─────
//
// Phase 0a Task T1: register the two dir-acc EMA slots so the
// fold-boundary path resets them to the random-guessing sentinel
// 0.5 (NOT 0.0 — directional accuracy has a meaningful baseline at
// 50%, and Pearl A's first-observation replacement fires on the
// first epoch of the new fold to track the new fold's signal
// without contamination from the previous fold's terminal value).
//
// Slots 372 (TARGET_DIR_ACC), 376..380 (DIR_SKILL_BONUS_ALPHA/BETA,
// LUCK_WIN_DISCOUNT, SKILL_BONUS_CAP_RATIO) are constructor-
// initialised Invariant-1 anchors and need NO registry entry —
// they must never reach sentinel 0 between folds, so the
// constructor-write path keeps them populated with their defaults
// for the lifetime of the trainer. Slot 375 (AUX_DIR_PREDICTION)
// is per-bar overwritten by the aux head and needs no reset
// entry either. Spec: docs/superpowers/specs/2026-05-04-sp13-redefine-success-for-predictive-skill.md
RegistryEntry {
name: "sp13_aux_dir_acc_short_ema",
category: ResetCategory::FoldReset,
description: "ISV[AUX_DIR_ACC_SHORT_EMA_INDEX=373] — SP13 Phase 0a fast EMA of directional accuracy (α=0.3). Produced by `apply_fixed_alpha_ema_kernel` chained off `aux_dir_acc_reduce_kernel` (the fixed-α applicator preserves the short/long timescale split that Wiener-optimal `apply_pearls_ad_kernel` would collapse). Consumed by the Phase 0b aux-w controller as the deficit numerator (`deficit = max(0, target short)`) and the stagnation comparator. FoldReset sentinel 0.5 (random-guessing baseline) so Pearl A bootstraps from the new fold's first observation per `pearl_first_observation_bootstrap.md` — the EMA tracks the new fold's signal without bias from the previous fold's terminal accuracy. Spec §implementation phases.",
},
RegistryEntry {
name: "sp13_aux_dir_acc_long_ema",
category: ResetCategory::FoldReset,
description: "ISV[AUX_DIR_ACC_LONG_EMA_INDEX=374] — SP13 Phase 0a slow EMA of directional accuracy (α=0.05) feeding the stagnation detector. Produced by `apply_fixed_alpha_ema_kernel` chained off `aux_dir_acc_reduce_kernel` (the fixed-α applicator preserves the short/long timescale split that Wiener-optimal `apply_pearls_ad_kernel` would collapse). Consumed by the Phase 0b aux-w controller's stagnation term (`stagnation = max(0, 1 (short long) / max(0.005, deficit))`) so a stalled short EMA decays aux_w back toward base — the deliberate 'we tried, it didn't help' signal that distinguishes Phase 0b's controller from SP11's inverted formula. FoldReset sentinel 0.5 (random-guessing baseline) so Pearl A bootstraps from the new fold's first observation per `pearl_first_observation_bootstrap.md`. Spec §implementation phases.",
},
// SP13 v3 P0a.T3 (2026-05-04): Hold-pricing observed-rate EMA.
// Slots 380 (HOLD_COST) and 381 (HOLD_RATE_TARGET) are
// constructor-initialised Invariant-1 anchors and need NO
// registry entry — slot 380 is rewritten every step by the
// host-side controller in `training_loop.rs`, and slot 381 is
// a static default (0.20) that must never reach sentinel 0
// between folds. Slot 382 is the per-fold observed-rate EMA
// produced by `apply_fixed_alpha_ema_kernel` chained off
// `hold_rate_observer_kernel`; sentinel 0.0 lets Pearl A
// bootstrap from the new fold's first observation per
// `pearl_first_observation_bootstrap.md` so the EMA tracks
// the new fold's Hold-rate without bias from the previous
// fold's terminal value.
RegistryEntry {
name: "sp13_hold_rate_observed_ema",
category: ResetCategory::FoldReset,
description: "ISV[HOLD_RATE_OBSERVED_EMA_INDEX=382] — SP13 v3 P0a.T3 per-fold EMA of the per-step Hold-pick rate `count(action_dir == DIR_HOLD) / batch_size`. Produced by `apply_fixed_alpha_ema_kernel` chained off `hold_rate_observer_kernel` (added in P0a.T3). Consumed by the host-side Hold-cost controller in `training_loop.rs`: `excess = max(0, observed target); hold_cost = HOLD_COST_BASE × (1 + 5 × excess)` clamped to `[0.5×base, 5.0×base]`, written to ISV[HOLD_COST_INDEX=380]. The reward-composition site in `experience_kernels.cu` then subtracts the cost on every Hold-action bar — pricing the action so the policy uses Hold deliberately rather than as a free CQL-bias-anchored default. FoldReset sentinel 0.0 so Pearl A bootstraps from the new fold's first observation; the controller relaxes back to baseline cost (HOLD_COST_BASE × 0.5 floor) when observed ≤ target. Spec §'Change 1: Price Hold (replaces v2's Eliminate Hold)'.",
},
// 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

View File

@@ -3563,6 +3563,63 @@ impl DQNTrainer {
"SP11 mag_ratio_compute launch failed");
}
// SP13 v3 P0a.T3 (2026-05-04): Hold-pricing
// controller. Reads the observed Hold-pick rate
// EMA at ISV[HOLD_RATE_OBSERVED_EMA_INDEX=382]
// (populated by `hold_rate_observer_kernel` +
// chained Pearls A+D — observer launch wires
// alongside the dir_acc launch in P0a.T4) and
// the static Hold-rate target at
// ISV[HOLD_RATE_TARGET_INDEX=381] (constructor-
// initialised to 0.20). Computes the priced
// per-bar Hold cost
// excess = max(0, observed target)
// cost = HOLD_COST_BASE × (1 + 5 × excess)
// cost = clamp(cost, base × 0.5, base × 5.0)
// and writes it back to ISV[HOLD_COST_INDEX=380].
// The reward composition site in
// `experience_kernels.cu` reads slot 380 each
// bar where action == DIR_HOLD and subtracts
// the cost — pricing the action so the policy
// uses Hold deliberately rather than as a free
// CQL-bias-anchored default.
//
// Cold-start (epoch 1, slot 382 still at fold-
// reset sentinel 0.0): observed=0, target=0.20,
// excess=0 → cost = base × 1.0 = HOLD_COST_BASE,
// clamped to [base × 0.5, base × 5.0] →
// baseline carry cost. Pearl A's first-
// observation replacement consumes the sentinel
// when the observer first fires.
//
// Per `feedback_isv_for_adaptive_bounds`: base
// cost is the only constant; rate target,
// observed rate, and cost output are ISV-driven.
// Per `feedback_no_cpu_compute_strict`: the
// formula here is host-side controller arithmetic
// on already-smoothed scalars — no per-sample
// GPU compute path is bypassed (the observer +
// Pearls A+D + per-bar reward subtraction all
// run on GPU).
{
use crate::cuda_pipeline::sp5_isv_slots::{
HOLD_COST_INDEX, HOLD_RATE_TARGET_INDEX,
HOLD_RATE_OBSERVED_EMA_INDEX,
HOLD_COST_BASE, HOLD_COST_CONTROLLER_GAIN,
HOLD_COST_FLOOR_RATIO, HOLD_COST_CEIL_RATIO,
};
let trainer = fused.trainer();
let observed = trainer.read_isv_signal_at(HOLD_RATE_OBSERVED_EMA_INDEX);
let target = trainer.read_isv_signal_at(HOLD_RATE_TARGET_INDEX);
let excess = (observed - target).max(0.0);
let cost = HOLD_COST_BASE * (1.0 + HOLD_COST_CONTROLLER_GAIN * excess);
let cost = cost.clamp(
HOLD_COST_BASE * HOLD_COST_FLOOR_RATIO,
HOLD_COST_BASE * HOLD_COST_CEIL_RATIO,
);
trainer.write_isv_signal_at(HOLD_COST_INDEX, cost);
}
// SP11 Fix 39 (2026-05-04, A1.2): per-bar
// saboteur engagement canary producer. Reads
// the per-bar saboteur Δreward signal written
@@ -3914,6 +3971,30 @@ impl DQNTrainer {
}
}
// SP13 Phase 0a P0a.T4 (2026-05-04): per-step aux
// directional-accuracy producer chain. The orchestrator
// reads the captured-forward-graph `aux_nb_pred_buf` +
// `aux_nb_label_buf` (just populated above by the same
// per-step block that fires `launch_aux_heads_loss_ema`),
// reduces them into the trainer's mapped-pinned
// `aux_dir_acc_buf [3]`, then chains:
// - fixed-α EMA (α=0.3, sentinel 0.5) → ISV[373]
// - fixed-α EMA (α=0.05, sentinel 0.5) → ISV[374]
// - mean(tanh(aux_pred)) → ISV[375]
// All on the same stream as the producer, so no host
// sync is required between captured graph and consumer.
// Producer-only — no consumer kernel reads slots 373/374/
// 375 in this commit; Phase 0b wires the controller and
// the Q-head input layer that reads slot 375. Same
// post-cascade-fix invariant as `launch_aux_heads_loss_ema`:
// ISV producer launches MUST run BEFORE the HEALTH_DIAG
// line emit further down.
if let Some(ref fused) = self.fused_ctx {
if let Err(e) = fused.trainer().launch_sp13_aux_dir_metrics() {
tracing::warn!("SP13 P0a.T4 aux_dir_metrics launch failed: {e}");
}
}
// Phase 3 T3.5: MoE expert-utilisation EMA + gate entropy EMA.
// Reads `moe_gate_softmax_buf [B, K]` (valid after the captured
// forward graph ran) and EMA-updates ISV[118..127).
@@ -4709,6 +4790,55 @@ impl DQNTrainer {
);
}
// SP13 Phase 0a P0a.T4 (2026-05-04): aux directional-accuracy +
// Hold-pricing HEALTH_DIAG. Reads ISV slots populated by the
// per-step producer chains above:
// ISV[373..375) — aux dir-acc fast/slow EMAs (α=0.3 / 0.05)
// ISV[375] — mean(tanh(aux_pred)) per-step state
// ISV[372] — directional accuracy target (static 0.55)
// ISV[380..383) — Hold-pricing controller output / target /
// observed-rate EMA (slot 380 written by the
// per-epoch host controller at training_loop:3604;
// slot 382 written by the per-step
// gpu_experience_collector chain)
// Diagnostic only — exposes the SP13 chain's signal flow before
// Phase 0b wires the consumers (aux-w controller, dir-skill
// bonus, Q-head slot-375 input).
{
use crate::cuda_pipeline::sp13_isv_slots::{
TARGET_DIR_ACC_INDEX,
AUX_DIR_ACC_SHORT_EMA_INDEX, AUX_DIR_ACC_LONG_EMA_INDEX,
AUX_DIR_PREDICTION_INDEX,
HOLD_COST_INDEX, HOLD_RATE_TARGET_INDEX,
HOLD_RATE_OBSERVED_EMA_INDEX,
};
let (
target_dir_acc, dir_short, dir_long, aux_pred_tanh,
hold_cost, hold_target, hold_observed,
) = if let Some(ref fused) = self.fused_ctx {
let trainer = fused.trainer();
(
trainer.read_isv_signal_at(TARGET_DIR_ACC_INDEX),
trainer.read_isv_signal_at(AUX_DIR_ACC_SHORT_EMA_INDEX),
trainer.read_isv_signal_at(AUX_DIR_ACC_LONG_EMA_INDEX),
trainer.read_isv_signal_at(AUX_DIR_PREDICTION_INDEX),
trainer.read_isv_signal_at(HOLD_COST_INDEX),
trainer.read_isv_signal_at(HOLD_RATE_TARGET_INDEX),
trainer.read_isv_signal_at(HOLD_RATE_OBSERVED_EMA_INDEX),
)
} else {
(0.55, 0.5, 0.5, 0.0, 0.001, 0.20, 0.0)
};
tracing::info!(
"HEALTH_DIAG[{}]: aux_dir_acc target={:.4} short={:.4} long={:.4} pred_tanh={:.4}",
epoch, target_dir_acc, dir_short, dir_long, aux_pred_tanh,
);
tracing::info!(
"HEALTH_DIAG[{}]: hold_pricing observed_rate={:.4} target={:.4} cost={:.6}",
epoch, hold_observed, hold_target, hold_cost,
);
}
// Phase 3 T3.5: MoE expert-utilisation + gate entropy HEALTH_DIAG.
// Extended 2026-04-27: also emits the adaptive λ_eff slot so the
// controller is observable per epoch — see `launch_moe_lambda_eff_update`.
@@ -7488,6 +7618,54 @@ impl DQNTrainer {
}
}
}
// SP13 (2026-05-04) Phase 0a: directional-accuracy EMAs.
// Sentinel 0.5 (NOT 0.0) is the random-guessing baseline for
// binary directional accuracy. Pearl A's first-observation
// replacement fires on the first epoch of the new fold so the
// EMA tracks the new fold's signal without bias from the
// previous fold's terminal value. Companion Wiener-state slots
// (offsets in `wiener_state_buf`) are reset by the existing bulk
// memset covered by the `sp5_wiener_state` registry entry's
// dispatch arm — both halves of the Pearl A sentinel contract
// reset together per `feedback_no_partial_refactor.md`.
"sp13_aux_dir_acc_short_ema" => {
if let Some(ref fused) = self.fused_ctx {
use crate::cuda_pipeline::sp5_isv_slots::{
AUX_DIR_ACC_SHORT_EMA_INDEX, DIR_ACC_EMA_SENTINEL,
};
fused.trainer().write_isv_signal_at(
AUX_DIR_ACC_SHORT_EMA_INDEX,
DIR_ACC_EMA_SENTINEL,
);
}
}
"sp13_aux_dir_acc_long_ema" => {
if let Some(ref fused) = self.fused_ctx {
use crate::cuda_pipeline::sp5_isv_slots::{
AUX_DIR_ACC_LONG_EMA_INDEX, DIR_ACC_EMA_SENTINEL,
};
fused.trainer().write_isv_signal_at(
AUX_DIR_ACC_LONG_EMA_INDEX,
DIR_ACC_EMA_SENTINEL,
);
}
}
// SP13 v3 P0a.T3 (2026-05-04): Hold-pricing observed-rate EMA.
// Sentinel 0.0 — no Hold-pick observations at fold start. The
// companion wiener-state slot (offset = SP4_PRODUCER_COUNT × 3
// + (382 SP5_SLOT_BASE) × 3) resets in the existing bulk
// memset covered by the `sp5_wiener_state` registry entry's
// dispatch arm; both halves of the Pearl A sentinel contract
// reset together per `feedback_no_partial_refactor.md`.
"sp13_hold_rate_observed_ema" => {
if let Some(ref fused) = self.fused_ctx {
use crate::cuda_pipeline::sp5_isv_slots::HOLD_RATE_OBSERVED_EMA_INDEX;
fused.trainer().write_isv_signal_at(
HOLD_RATE_OBSERVED_EMA_INDEX,
0.0,
);
}
}
// SP11 Task A2 (2026-05-04): novelty visit-count hash table
// reset arm — closes the A0 deferral per the registry entry's
// docstring. 1M f32 slots, zero-filled via mapped-pinned host

View File

@@ -0,0 +1,731 @@
#![allow(unsafe_code)] // CUDA kernel launch + mapped-pinned memory.
//! SP13 Phase 0a directional-accuracy reducer GPU oracle tests.
//!
//! Validates the single-block tree-reduce kernel introduced by P0a.T2:
//! `aux_dir_acc_reduce_kernel.cu`
//!
//! Six oracle cases drive synthetic `(aux_pred, next_bar_label)` pairs
//! through the production kernel and verify the GPU-computed
//! `(dir_acc, pos_pred_frac, pos_label_frac)` triple against analytically-
//! known expected values. Per `feedback_no_cpu_test_fallbacks.md`: GPU
//! oracle only, no CPU reference impl. Per
//! `feedback_no_htod_htoh_only_mapped_pinned.md`: every CPU↔GPU buffer
//! is a `MappedF32Buffer` (cuMemHostAlloc with DEVICEMAP|PORTABLE);
//! zero `htod_copy`, zero `dtoh_sync_copy`. Tests are NOT exempt from
//! this rule.
//!
//! All tests are `#[ignore = "requires GPU"]`-gated to match every other
//! GPU oracle test in this crate (sp4/sp5/sp11/sp12). Run on a GPU host:
//!
//! SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 \
//! cargo test -p ml --test sp13_phase0_oracle_tests --features cuda \
//! -- --ignored --nocapture
//!
//! Sign convention under test (kernel side: `(p > 0.0f) ? 1 : 0`):
//! - `p > 0` → predicts class 1 (positive)
//! - `p <= 0` → predicts class 0 (negative) — incl. `0.0` and `-0.0`
//! - `label == 0.0` → no-signal bar, excluded from denominator entirely
//!
//! Sentinel:
//! - When the entire batch is invalid (empty / all-zero labels),
//! `(dir_acc, pos_pred_frac, pos_label_frac) = (0.5, 0.5, 0.5)`.
//! Matches `DIR_ACC_EMA_SENTINEL` in `sp13_isv_slots.rs` per
//! `pearl_first_observation_bootstrap.md`.
#![cfg(feature = "cuda")]
use std::sync::Arc;
use cudarc::driver::{CudaContext, CudaFunction, CudaStream, LaunchConfig, PushKernelArg};
use ml::cuda_pipeline::mapped_pinned::{MappedF32Buffer, MappedI32Buffer};
/// Test-only cubin built by `crates/ml/build.rs`.
const SP13_AUX_DIR_ACC_REDUCE_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/aux_dir_acc_reduce_kernel.cubin"));
/// Resolve a CUDA stream against device 0. Mirrors `make_test_stream` in
/// every other oracle test in this crate (`sp4_producer_unit_tests.rs`,
/// `sp11_producer_unit_tests.rs`, `sp12_reward_math_tests.rs`).
fn make_test_stream() -> Arc<CudaStream> {
let ctx = CudaContext::new(0).expect("CUDA context — is a GPU available?");
ctx.default_stream()
}
/// Load the reducer kernel function from the embedded cubin.
fn load_kernel(stream: &Arc<CudaStream>) -> CudaFunction {
let module = stream
.context()
.load_cubin(SP13_AUX_DIR_ACC_REDUCE_CUBIN.to_vec())
.expect("load aux_dir_acc_reduce cubin");
module
.load_function("aux_dir_acc_reduce_kernel")
.expect("load aux_dir_acc_reduce_kernel function")
}
/// Drive one launch of the production reducer kernel with the given
/// per-bar prediction + label slices and return
/// `[dir_acc, pos_pred_frac, pos_label_frac]`.
///
/// All buffers are mapped-pinned (`MappedF32Buffer`) per
/// `feedback_no_htod_htoh_only_mapped_pinned.md`. The kernel writes the
/// 3-element output through `dev_ptr` with `__threadfence_system()`; the
/// test reads through `read_all()`'s volatile read after stream sync.
///
/// Block dim 256, shared mem `4 × 256 × sizeof(int) = 4 KB` — matches
/// the production launcher's config exactly.
fn run_dir_acc(
stream: &Arc<CudaStream>,
f: &CudaFunction,
pred: &[f32],
label: &[f32],
) -> [f32; 3] {
assert_eq!(
pred.len(),
label.len(),
"pred and label must have matching length; got {} vs {}",
pred.len(),
label.len()
);
let n = pred.len();
// Empty-batch case: even if `n == 0` the kernel still needs valid
// device pointers for the input args (CUDA dereferences them only
// inside the strided loop, which doesn't execute, but the launch
// builder still passes the pointers). Allocate 1-element placeholders
// so the dev_ptr is non-zero.
let alloc_len = n.max(1);
// Safety: CUDA context active on this thread (resolved via the
// stream's context above). All CPU↔GPU buffers are mapped-pinned.
let pred_buf = unsafe { MappedF32Buffer::new(alloc_len) }
.expect("alloc pred buffer (mapped-pinned)");
let label_buf = unsafe { MappedF32Buffer::new(alloc_len) }
.expect("alloc label buffer (mapped-pinned)");
if n > 0 {
pred_buf.write_from_slice(pred);
label_buf.write_from_slice(label);
}
let out_buf = unsafe { MappedF32Buffer::new(3) }
.expect("alloc out buffer (3 f32 mapped-pinned)");
let pred_dev = pred_buf.dev_ptr;
let label_dev = label_buf.dev_ptr;
let out_dev = out_buf.dev_ptr;
let batch_size_i32: i32 = n as i32;
let bdim: u32 = 256;
unsafe {
stream
.launch_builder(f)
.arg(&pred_dev)
.arg(&label_dev)
.arg(&batch_size_i32)
.arg(&out_dev)
.launch(LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (bdim, 1, 1),
shared_mem_bytes: 4 * bdim * std::mem::size_of::<i32>() as u32,
})
.expect("launch aux_dir_acc_reduce_kernel");
}
stream.synchronize().expect("sync after aux_dir_acc_reduce launch");
let out = out_buf.read_all();
[out[0], out[1], out[2]]
}
// ─── Six P0a.T2 oracle cases ─────────────────────────────────────────────
/// All four bars predict the correct sign → dir_acc = 1.0.
#[test]
#[ignore = "requires GPU"]
fn dir_acc_all_correct_returns_one() {
let stream = make_test_stream();
let f = load_kernel(&stream);
let pred = vec![ 1.0_f32, 1.0, -1.0, -1.0];
let label = vec![ 1.0_f32, 1.0, -1.0, -1.0];
let out = run_dir_acc(&stream, &f, &pred, &label);
assert!(
(out[0] - 1.0).abs() < 1e-6,
"expected dir_acc = 1.0, got {}",
out[0]
);
}
/// All four bars predict the wrong sign → dir_acc = 0.0.
#[test]
#[ignore = "requires GPU"]
fn dir_acc_all_wrong_returns_zero() {
let stream = make_test_stream();
let f = load_kernel(&stream);
let pred = vec![ 1.0_f32, 1.0, -1.0, -1.0];
let label = vec![-1.0_f32, -1.0, 1.0, 1.0];
let out = run_dir_acc(&stream, &f, &pred, &label);
assert!(
out[0].abs() < 1e-6,
"expected dir_acc = 0.0, got {}",
out[0]
);
}
/// Half right + asymmetric prediction/label distributions.
///
/// pred = [+, +, +, +] → 4 positive predictions / 4 valid bars = 1.0
/// label = [+, +, -, -] → 2 positive labels / 4 valid bars = 0.5
/// dir_acc = 2/4 = 0.5 (bars 0+1 correct, bars 2+3 wrong).
#[test]
#[ignore = "requires GPU"]
fn dir_acc_half_correct() {
let stream = make_test_stream();
let f = load_kernel(&stream);
let pred = vec![ 1.0_f32, 1.0, 1.0, 1.0];
let label = vec![ 1.0_f32, 1.0, -1.0, -1.0];
let out = run_dir_acc(&stream, &f, &pred, &label);
assert!(
(out[0] - 0.5).abs() < 1e-6,
"expected dir_acc = 0.5, got {}",
out[0]
);
assert!(
(out[1] - 1.0).abs() < 1e-6,
"expected pos_pred_frac = 1.0 (4/4 positive predictions), got {}",
out[1]
);
assert!(
(out[2] - 0.5).abs() < 1e-6,
"expected pos_label_frac = 0.5 (2/4 positive labels), got {}",
out[2]
);
}
/// Bars whose label is exactly 0.0 are excluded from the denominator
/// entirely.
///
/// pred = [+, +, -]
/// label = [+, 0, -] ← bar 1 is no-signal and skipped
/// valid bars = 2 (indexes 0 and 2). Both correct → dir_acc = 2/2 = 1.0.
/// If the kernel mistakenly counted bar 1 as `label_pos = 0` (matching
/// `pred_pos = 1`?) it would be wrong; if it counted bar 1 as valid with
/// `label_pos = 0` and `pred_pos = 1`, dir_acc would drop to 2/3 ≈ 0.667.
/// Either failure mode is caught by the strict 1.0 expectation.
#[test]
#[ignore = "requires GPU"]
fn dir_acc_skips_zero_labels() {
let stream = make_test_stream();
let f = load_kernel(&stream);
let pred = vec![ 1.0_f32, 1.0, -1.0];
let label = vec![ 1.0_f32, 0.0, -1.0];
let out = run_dir_acc(&stream, &f, &pred, &label);
assert!(
(out[0] - 1.0).abs() < 1e-6,
"expected dir_acc = 1.0 (bar 1 with label=0 excluded), got {}",
out[0]
);
}
/// Sign convention: `(p > 0.0f) ? 1 : 0` treats both `+0.0` and `-0.0`
/// as the negative class.
///
/// pred = [0.0, -0.0] ← both predict class 0 (negative)
/// label = [-1.0, -1.0] ← both negative
/// dir_acc = 2/2 = 1.0 (both correct).
#[test]
#[ignore = "requires GPU"]
fn dir_acc_zero_pred_is_negative() {
let stream = make_test_stream();
let f = load_kernel(&stream);
let pred = vec![ 0.0_f32, -0.0];
let label = vec![-1.0_f32, -1.0];
let out = run_dir_acc(&stream, &f, &pred, &label);
assert!(
(out[0] - 1.0).abs() < 1e-6,
"expected dir_acc = 1.0 (zero pred → class 0 → matches negative label), got {}",
out[0]
);
}
/// Empty batch (or all-zero-label batch) returns the random-baseline
/// sentinel 0.5 across all three outputs. Matches
/// `DIR_ACC_EMA_SENTINEL` in `sp13_isv_slots.rs` per
/// `pearl_first_observation_bootstrap.md` — the downstream EMA's first-
/// observation replacement consumes the sentinel naturally.
#[test]
#[ignore = "requires GPU"]
fn dir_acc_empty_batch_returns_sentinel() {
let stream = make_test_stream();
let f = load_kernel(&stream);
let pred: Vec<f32> = vec![];
let label: Vec<f32> = vec![];
let out = run_dir_acc(&stream, &f, &pred, &label);
assert!(
(out[0] - 0.5).abs() < 1e-6,
"expected dir_acc sentinel = 0.5 on empty batch, got {}",
out[0]
);
assert!(
(out[1] - 0.5).abs() < 1e-6,
"expected pos_pred_frac sentinel = 0.5 on empty batch, got {}",
out[1]
);
assert!(
(out[2] - 0.5).abs() < 1e-6,
"expected pos_label_frac sentinel = 0.5 on empty batch, got {}",
out[2]
);
}
// ─── SP13 v3 P0a.T3 Hold-rate observer oracle cases ─────────────────────
//
// Validates the second SP13 Phase 0a single-block tree-reduce:
// `hold_rate_observer_kernel.cu`
//
// Drives synthetic per-bar PACKED factored-action tiles (`batch_actions [B]`
// of `i32`) through the production kernel and verifies the GPU-computed
// fraction `count(decoded_dir == DIR_HOLD) / batch_size` against
// analytically-known expected values. Same `MappedF32Buffer` discipline
// as the dir-acc oracle above; the actions tile is allocated through a
// dedicated `MappedI32Buffer` helper since the production input is `i32`
// rather than `f32`.
//
// Action encoding under test (mirrors `experience_action_select` at
// `experience_kernels.cu:1453`):
//
// action_idx = dir * (NUM_MAGNITUDES * NUM_ORD * NUM_URG)
// + mag * (NUM_ORD * NUM_URG)
// + ord * NUM_URG
// + urg
//
// Production branch sizes are NUM_MAGNITUDES = NUM_ORD = NUM_URG = 3
// (see `state_layout.cuh`); the per-direction stride is 27.
//
// Sign convention under test:
// - decoded `dir == DIR_HOLD` (=1) — bars whose packed action_idx
// decodes to direction Hold contribute 1 to the count
// - any other direction (`DIR_SHORT=0`, `DIR_LONG=2`, `DIR_FLAT=3`)
// contributes 0
// - empty batch → out = 0.0 (matches the Pearl A sentinel for slot
// 382 / `HOLD_RATE_OBSERVED_EMA_INDEX`)
const SP13_HOLD_RATE_OBSERVER_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/hold_rate_observer_kernel.cubin"));
/// Production branch sizes (mirror `state_layout.cuh`: `NUM_MAGNITUDES`,
/// `NUM_ORD`, `NUM_URG`). Authoritative source — every consumer of the
/// packed factored encoding must use the same values per
/// `feedback_no_partial_refactor.md`.
const TEST_NUM_MAGNITUDES: i32 = 3;
const TEST_NUM_ORD: i32 = 3;
const TEST_NUM_URG: i32 = 3;
/// Pack a 4-tuple `(dir, mag, ord, urg)` into a factored action_idx using
/// the same encoding as `experience_action_select`. Direction stride is
/// `M * O * U`; magnitude stride is `O * U`; order stride is `U`.
fn pack_action(dir: i32, mag: i32, ord: i32, urg: i32) -> i32 {
dir * (TEST_NUM_MAGNITUDES * TEST_NUM_ORD * TEST_NUM_URG)
+ mag * (TEST_NUM_ORD * TEST_NUM_URG)
+ ord * TEST_NUM_URG
+ urg
}
fn load_hold_rate_observer(stream: &Arc<CudaStream>) -> CudaFunction {
let module = stream
.context()
.load_cubin(SP13_HOLD_RATE_OBSERVER_CUBIN.to_vec())
.expect("load hold_rate_observer cubin");
module
.load_function("hold_rate_observer_kernel")
.expect("load hold_rate_observer_kernel function")
}
/// Drive one launch of the production Hold-rate observer reducer with
/// the given per-bar direction-action slice and return the Hold-pick
/// fraction.
///
/// Block dim 256, shared mem `256 × sizeof(i32) = 1 KB` — matches the
/// production launcher's config exactly. All CPU↔GPU buffers are
/// mapped-pinned (`MappedI32Buffer` for the i32 actions tile,
/// `MappedF32Buffer` for the f32 output) per
/// `feedback_no_htod_htoh_only_mapped_pinned.md`.
fn run_hold_rate_observer(
stream: &Arc<CudaStream>,
f: &CudaFunction,
actions: &[i32],
) -> f32 {
let n = actions.len();
// Empty-batch case: kernel still needs a non-null device pointer for
// the actions arg (strided loop is empty but launch builder still
// passes the pointer). Mirrors `run_dir_acc`.
let alloc_len = n.max(1);
// Safety: CUDA context active on this thread (resolved via the
// stream's context above). All CPU↔GPU buffers are mapped-pinned.
let actions_buf = unsafe { MappedI32Buffer::new(alloc_len) }
.expect("alloc actions buffer (mapped-pinned)");
if n > 0 {
actions_buf.write_from_slice(actions);
}
let out_buf = unsafe { MappedF32Buffer::new(1) }
.expect("alloc out buffer (1 f32 mapped-pinned)");
let actions_dev = actions_buf.dev_ptr;
let out_dev = out_buf.dev_ptr;
let batch_size_i32: i32 = n as i32;
let bdim: u32 = 256;
unsafe {
stream
.launch_builder(f)
.arg(&actions_dev)
.arg(&batch_size_i32)
.arg(&out_dev)
.launch(LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (bdim, 1, 1),
shared_mem_bytes: bdim * std::mem::size_of::<i32>() as u32,
})
.expect("launch hold_rate_observer_kernel");
}
stream.synchronize().expect("sync after hold_rate_observer launch");
out_buf.read_all()[0]
}
/// Every action in the batch decodes to Hold direction → out = 1.0.
/// Uses non-zero (mag, ord, urg) sub-indices to exercise the divisor —
/// would catch a kernel that mistakenly treated the raw action_idx as the
/// direction (it's only the direction *after* dividing by M*O*U).
#[test]
#[ignore = "requires GPU"]
fn hold_rate_observer_all_hold_returns_one() {
let stream = make_test_stream();
let f = load_hold_rate_observer(&stream);
// Mix Hold actions across different (mag, ord, urg) sub-indices to
// verify the kernel's divisor. action_idx for Hold ranges over
// [27, 54): every value in that interval decodes to dir=1.
let actions: Vec<i32> = vec![
pack_action(1, 0, 0, 0), // 27
pack_action(1, 1, 0, 0), // 36
pack_action(1, 0, 2, 0), // 33
pack_action(1, 2, 2, 2), // 53 (Hold high-corner)
pack_action(1, 0, 0, 1), // 28
pack_action(1, 1, 1, 1), // 40
pack_action(1, 2, 0, 0), // 45
pack_action(1, 0, 1, 2), // 32
pack_action(1, 1, 2, 1), // 43
pack_action(1, 0, 0, 2), // 29
];
let out = run_hold_rate_observer(&stream, &f, &actions);
assert!(
(out - 1.0).abs() < 1e-6,
"expected hold_rate = 1.0 on all-Hold batch (decoded), got {}",
out
);
}
/// No action in the batch decodes to Hold (mix of Short=0/Long=2/Flat=3
/// across all (mag, ord, urg)) → out = 0.0. Verifies that no off-by-one
/// in the divisor flips a non-Hold packed value into the Hold band.
#[test]
#[ignore = "requires GPU"]
fn hold_rate_observer_no_hold_returns_zero() {
let stream = make_test_stream();
let f = load_hold_rate_observer(&stream);
// Mix non-Hold direction values across the (mag, ord, urg) sub-space.
// Short band = [0, 27), Long band = [54, 81), Flat band = [81, 108).
// None of these decode to dir=1.
let actions: Vec<i32> = vec![
pack_action(0, 0, 0, 0), // 0 (Short low-corner)
pack_action(0, 2, 2, 2), // 26 (Short high-corner)
pack_action(2, 0, 0, 0), // 54 (Long low-corner)
pack_action(2, 2, 2, 2), // 80 (Long high-corner)
pack_action(3, 0, 0, 0), // 81 (Flat low-corner)
pack_action(3, 2, 2, 2), // 107 (Flat high-corner)
];
let out = run_hold_rate_observer(&stream, &f, &actions);
assert!(
out.abs() < 1e-6,
"expected hold_rate = 0.0 on no-Hold batch (decoded), got {}",
out
);
}
/// Half the batch decodes to Hold → out = 0.5. Verifies the tree-reduce
/// arithmetic produces the correct fraction (not just the boolean any-
/// Hold/no-Hold extremes the previous two tests cover) AND that every
/// non-Hold direction (Short/Long/Flat) decodes correctly to non-Hold —
/// would catch an off-by-one comparing against DIR_SHORT instead of
/// DIR_HOLD.
#[test]
#[ignore = "requires GPU"]
fn hold_rate_observer_half_hold_returns_half() {
let stream = make_test_stream();
let f = load_hold_rate_observer(&stream);
// 4 Hold + 4 non-Hold = 0.5 fraction. Each Hold has different
// (mag, ord, urg); the 4 non-Hold span all 3 alternative directions.
let actions: Vec<i32> = vec![
pack_action(1, 0, 0, 0), // Hold
pack_action(0, 1, 1, 1), // Short
pack_action(1, 1, 1, 1), // Hold
pack_action(2, 0, 0, 0), // Long
pack_action(1, 2, 2, 2), // Hold
pack_action(3, 1, 1, 1), // Flat
pack_action(1, 0, 1, 2), // Hold
pack_action(0, 2, 2, 2), // Short
];
let out = run_hold_rate_observer(&stream, &f, &actions);
assert!(
(out - 0.5).abs() < 1e-6,
"expected hold_rate = 0.5 on half-Hold batch (decoded), got {}",
out
);
}
// ─── SP13 P0a.T4 fixed-α EMA applicator oracle cases ────────────────────
//
// Validates the SP13 fixed-α EMA applicator kernel introduced by P0a.T4:
// `apply_fixed_alpha_ema_kernel.cu`
//
// Drives synthetic (sample, prev_state, alpha, sentinel) tuples through
// the production kernel and verifies the GPU-computed
// `(1-α) · prev + α · sample` blend (or sentinel-bootstrap direct
// replacement) against analytically-known expected values. Same
// `MappedF32Buffer` discipline as the other oracles; the kernel is N-slot
// generalisable but P0a always launches with N=1, so the tests stay at
// N=1 to cover the production launch shape.
const SP13_APPLY_FIXED_ALPHA_EMA_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/apply_fixed_alpha_ema_kernel.cubin"));
fn load_apply_fixed_alpha_ema(stream: &Arc<CudaStream>) -> CudaFunction {
let module = stream
.context()
.load_cubin(SP13_APPLY_FIXED_ALPHA_EMA_CUBIN.to_vec())
.expect("load apply_fixed_alpha_ema cubin");
module
.load_function("apply_fixed_alpha_ema_kernel")
.expect("load apply_fixed_alpha_ema_kernel function")
}
/// Drive one launch of the fixed-α EMA applicator on a 1-slot ISV
/// scratch buffer pre-seeded with `prev_state`. Returns the post-launch
/// ISV slot value.
fn run_fixed_alpha_ema(
stream: &Arc<CudaStream>,
f: &CudaFunction,
sample: f32,
prev_state: f32,
alpha: f32,
sentinel: f32,
) -> f32 {
// Allocate a 1-elem mapped-pinned ISV scratch + 1-elem sample buffer.
let isv_buf = unsafe { MappedF32Buffer::new(1) }
.expect("alloc isv buffer (mapped-pinned)");
isv_buf.write_from_slice(&[prev_state]);
let sample_buf = unsafe { MappedF32Buffer::new(1) }
.expect("alloc sample buffer (mapped-pinned)");
sample_buf.write_from_slice(&[sample]);
let sample_dev = sample_buf.dev_ptr;
let isv_dev = isv_buf.dev_ptr;
let n_slots: i32 = 1;
let isv_offset: i32 = 0;
unsafe {
stream
.launch_builder(f)
.arg(&sample_dev)
.arg(&n_slots)
.arg(&isv_offset)
.arg(&alpha)
.arg(&sentinel)
.arg(&isv_dev)
.launch(LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (1, 1, 1),
shared_mem_bytes: 0,
})
.expect("launch apply_fixed_alpha_ema_kernel");
}
stream.synchronize().expect("sync after apply_fixed_alpha_ema launch");
isv_buf.read_all()[0]
}
/// Standard blend: prev=0.5, sample=1.0, α=0.5 → 0.75.
/// Sentinel is intentionally distinct from `prev` so the bootstrap
/// branch does NOT fire — this test exercises the steady-state blend
/// formula `(1-α) · prev + α · sample`.
#[test]
#[ignore = "requires GPU"]
fn fixed_alpha_ema_steady_state_blend() {
let stream = make_test_stream();
let f = load_apply_fixed_alpha_ema(&stream);
let out = run_fixed_alpha_ema(
&stream, &f,
/* sample */ 1.0,
/* prev */ 0.5,
/* alpha */ 0.5,
/* sentinel */ -1.0, // far from prev — bootstrap MUST NOT fire
);
assert!(
(out - 0.75).abs() < 1e-6,
"expected (1-0.5)*0.5 + 0.5*1.0 = 0.75, got {}",
out
);
}
/// First-observation bootstrap: prev == sentinel → replace directly.
/// Mirrors `pearl_first_observation_bootstrap` — the SP13 dir-acc
/// EMAs use sentinel=0.5 and the hold-rate EMA uses sentinel=0.0;
/// after fold reset the slot holds the sentinel, and the very first
/// non-sentinel observation must replace it directly so the EMA tracks
/// the new fold's signal without the sentinel's bias.
#[test]
#[ignore = "requires GPU"]
fn fixed_alpha_ema_sentinel_bootstrap() {
let stream = make_test_stream();
let f = load_apply_fixed_alpha_ema(&stream);
// SP13 dir-acc sentinel = 0.5. First real observation = 0.62 (>
// baseline). The kernel must REPLACE prev directly, not blend —
// a blend at α=0.3 would produce 0.5*0.7 + 0.62*0.3 = 0.536 which
// would carry the sentinel bias into the EMA forever.
let out = run_fixed_alpha_ema(
&stream, &f,
/* sample */ 0.62,
/* prev */ 0.5,
/* alpha */ 0.3,
/* sentinel */ 0.5, // matches prev — bootstrap MUST fire
);
assert!(
(out - 0.62).abs() < 1e-6,
"expected sentinel bootstrap to replace directly with 0.62, got {}",
out
);
}
// ─── SP13 P0a.T4 aux-pred → ISV[375] tanh oracle cases ──────────────────
//
// Validates the SP13 aux-head per-bar prediction → ISV[AUX_DIR_PREDICTION_INDEX=375]
// tanh-bounded scalar producer kernel introduced by P0a.T4:
// `aux_pred_to_isv_tanh_kernel.cu`
//
// Drives synthetic per-bar `aux_pred [B]` tiles through the production
// kernel and verifies the GPU-computed `mean(tanh(aux_pred[i]))` ∈ [-1, +1]
// against analytically-known expected values.
const SP13_AUX_PRED_TO_ISV_TANH_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/aux_pred_to_isv_tanh_kernel.cubin"));
fn load_aux_pred_to_isv_tanh(stream: &Arc<CudaStream>) -> CudaFunction {
let module = stream
.context()
.load_cubin(SP13_AUX_PRED_TO_ISV_TANH_CUBIN.to_vec())
.expect("load aux_pred_to_isv_tanh cubin");
module
.load_function("aux_pred_to_isv_tanh_kernel")
.expect("load aux_pred_to_isv_tanh_kernel function")
}
/// Drive one launch of the aux-pred → ISV[375] tanh producer with the
/// given per-bar prediction slice. Returns ISV slot 0 post-launch.
fn run_aux_pred_to_isv_tanh(
stream: &Arc<CudaStream>,
f: &CudaFunction,
aux_pred: &[f32],
) -> f32 {
let n = aux_pred.len();
let alloc_len = n.max(1);
let pred_buf = unsafe { MappedF32Buffer::new(alloc_len) }
.expect("alloc pred buffer (mapped-pinned)");
if n > 0 {
pred_buf.write_from_slice(aux_pred);
}
// 1-elem ISV scratch — kernel only writes slot 0.
let isv_buf = unsafe { MappedF32Buffer::new(1) }
.expect("alloc isv buffer (mapped-pinned)");
let pred_dev = pred_buf.dev_ptr;
let isv_dev = isv_buf.dev_ptr;
let batch_i32: i32 = n as i32;
let isv_off_i32: i32 = 0;
let bdim: u32 = 256;
unsafe {
stream
.launch_builder(f)
.arg(&pred_dev)
.arg(&batch_i32)
.arg(&isv_off_i32)
.arg(&isv_dev)
.launch(LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (bdim, 1, 1),
shared_mem_bytes: bdim * std::mem::size_of::<f32>() as u32,
})
.expect("launch aux_pred_to_isv_tanh_kernel");
}
stream.synchronize().expect("sync after aux_pred_to_isv_tanh launch");
isv_buf.read_all()[0]
}
/// Symmetric batch around zero → mean(tanh) ≈ 0.0.
/// `tanh(±x)` is odd, so an exactly-symmetric input sums to zero.
#[test]
#[ignore = "requires GPU"]
fn aux_pred_tanh_symmetric_batch_returns_zero() {
let stream = make_test_stream();
let f = load_aux_pred_to_isv_tanh(&stream);
let pred = vec![1.0_f32, -1.0, 0.5, -0.5, 2.0, -2.0];
let out = run_aux_pred_to_isv_tanh(&stream, &f, &pred);
assert!(
out.abs() < 1e-6,
"expected mean(tanh) = 0.0 on symmetric batch, got {}",
out
);
}
/// All-zero batch → mean(tanh(0)) = 0.0.
#[test]
#[ignore = "requires GPU"]
fn aux_pred_tanh_all_zero_returns_zero() {
let stream = make_test_stream();
let f = load_aux_pred_to_isv_tanh(&stream);
let pred = vec![0.0_f32; 16];
let out = run_aux_pred_to_isv_tanh(&stream, &f, &pred);
assert!(
out.abs() < 1e-6,
"expected mean(tanh(0)) = 0.0, got {}",
out
);
}
/// Bounded by [-1, +1] regardless of input magnitude. tanh saturates
/// at ±1 for large |x|, so mean(tanh(large)) ≈ ±1; large negative
/// inputs land near -1 (well within the [-1, +1] bound).
#[test]
#[ignore = "requires GPU"]
fn aux_pred_tanh_large_magnitude_saturates_in_bounds() {
let stream = make_test_stream();
let f = load_aux_pred_to_isv_tanh(&stream);
let pred = vec![10.0_f32; 32]; // tanh(10) ≈ 1.0 to ~5 nines
let out = run_aux_pred_to_isv_tanh(&stream, &f, &pred);
assert!(
(out - 1.0).abs() < 1e-4 && out <= 1.0,
"expected mean(tanh(10)) ≈ 1.0 (saturated), got {}",
out
);
let pred_neg = vec![-10.0_f32; 32];
let out_neg = run_aux_pred_to_isv_tanh(&stream, &f, &pred_neg);
assert!(
(out_neg + 1.0).abs() < 1e-4 && out_neg >= -1.0,
"expected mean(tanh(-10)) ≈ -1.0 (saturated), got {}",
out_neg
);
}

View File

@@ -5732,3 +5732,72 @@ The B1b sharpe-degradation residual is **resolved**. The B1b
controller (z-score normalised mag-ratio canary) and the SP11
reward cap fix together close the within-fold degradation root
cause.
---
## SP13 P0a — Hold-pricing + dir_acc instrumentation (2026-05-04)
**Spec**: `docs/superpowers/specs/2026-05-04-sp13-redefine-success-for-predictive-skill.md` v3 (commit `ba83fcd1f`)
**Plan**: `docs/superpowers/plans/2026-05-04-sp13-redefine-success-for-predictive-skill.md` v3
**Hypothesis**: directional signal IS in the data; ~46% WR persists because Hold is FREE — CQL bias makes it the lazy default. Pricing Hold forces deliberate use; if WR climbs, hypothesis confirmed.
### Architectural changes
| Change | Mechanism |
|---|---|
| **Price Hold** (replaces v2's Eliminate Hold) | ISV-driven adaptive controller in `training_loop.rs`. Per-bar `reward -= isv[HOLD_COST_INDEX]` at 3 reward-composition sites in `experience_kernels.cu` when action == DIR_HOLD. 4-way action space stays — `ExposureLevel::Hold` preserved (audit revealed v2's `DirectionAction` enum doesn't exist; fused 8-variant `ExposureLevel` cascades through 77 files). |
| **Aux dir_acc instrumentation** | New `aux_dir_acc_reduce_kernel.cu` (single-block tree-reduce of correct/pos_pred/pos_label/valid → 3 scalars). Dual-EMA (short α=0.3, long α=0.05) feeds Phase 0b stagnation detector. |
| **Aux signal → Q-head input** | New `aux_pred_to_isv_tanh_kernel.cu` reduces aux scalar to ISV[375] for direction-Q-head consumption (Layer B will replace tanh(scalar) with softmax logit-diff). |
| **Hold-rate observer** | New `hold_rate_observer_kernel.cu` reads packed `batch_actions [B]`, decodes direction inline (`dir = action_idx / (NUM_MAGNITUDES * NUM_ORD * NUM_URG)`), reduces to `count(Hold) / B` scalar. Wired per-step in `gpu_experience_collector.rs` after `experience_action_select`. |
| **Fixed-α EMA applicator** | New `apply_fixed_alpha_ema_kernel.cu` — sibling of `apply_pearls_kernel.cu`. Wiener-optimal α* would collapse the short/long pair to identical values, defeating the stagnation detector; fixed-α preserves the timescale split. Sentinel-aware first-observation bootstrap per `pearl_first_observation_bootstrap`. |
### ISV slot allocation
11 new slots `[372..383)`:
| Slot | Name | Role | Reset |
|---|---|---|---|
| 372 | `TARGET_DIR_ACC_INDEX` | Default 0.55 — dir_acc target | static (constructor) |
| 373 | `AUX_DIR_ACC_SHORT_EMA_INDEX` | Fast EMA (α=0.3) | per-fold sentinel 0.5 |
| 374 | `AUX_DIR_ACC_LONG_EMA_INDEX` | Slow EMA (α=0.05) — stagnation detector | per-fold sentinel 0.5 |
| 375 | `AUX_DIR_PREDICTION_INDEX` | tanh(aux_pred) → direction Q-head input | per-bar overwrite |
| 376 | `DIR_SKILL_BONUS_ALPHA_INDEX` | Layer C bonus magnitude (correct dir) | static 1.0 |
| 377 | `DIR_SKILL_BONUS_BETA_INDEX` | Layer C penalty magnitude (wrong dir) | static 1.0 |
| 378 | `LUCK_WIN_DISCOUNT_INDEX` | Layer C lucky-win discount | static 0.3 |
| 379 | `SKILL_BONUS_CAP_RATIO_INDEX` | Layer C bonus cap as fraction of \|alpha\| | static 0.3 |
| 380 | `HOLD_COST_INDEX` | Per-bar Hold cost — controller output | static (constructor seeds with HOLD_COST_BASE=0.001) |
| 381 | `HOLD_RATE_TARGET_INDEX` | Default 0.20 — controller target | static |
| 382 | `HOLD_RATE_OBSERVED_EMA_INDEX` | Per-step EMA of Hold-pick rate | per-fold sentinel 0.0 |
`SP5_SLOT_END = 383`, `ISV_TOTAL_DIM = 383`. Layout fingerprint extended with all 11 slot names.
### Test coverage
`crates/ml/tests/sp13_phase0_oracle_tests.rs` — **14 GPU oracle tests on RTX 3050 Ti, all passing**:
- 6 dir_acc reduction tests (T2)
- 3 hold_rate observer tests (T3 v3 — packed `batch_actions` decode)
- 2 fixed-α EMA tests (T4 — steady-state blend + sentinel bootstrap)
- 3 aux_pred → tanh tests (T4 — symmetric/all-zero/saturating)
SP12 14/14 + SP11 11/11 oracle tests still green (no regression).
### What's deferred to subsequent tasks
- **Layer B**: aux head regression → 30-bar binary classification (replaces `tanh(scalar)` in slot 375 with softmax `logit_diff`). 5-epoch smoke after green Phase 0a.
- **Layer C**: alpha-vs-benchmark (DROPPED v2) → direction-skill bonus + luck-win discount with bounded calibration (slots 376-379 populated but unused until then).
- **Layer D**: 30-epoch L40S validation + close-out commit with 3 new pearls.
### Pearls (post-validation)
To be written if Phase 0a confirms hypothesis:
- `pearl_redefine_success_for_predictive_skill`
- `pearl_skill_bonus_must_be_alpha_bounded` (calibration lesson from spec v1→v2 review)
- `pearl_reward_quadrant_audit_required` (meta-pearl from v1 review — every reward change needs a 4-quadrant worked example)
### Tension acknowledged in spec
Per-bar Hold cost is per-bar reward shaping, which `pearl_event_driven_reward_density_alignment` warns against. Spec justifies as: (a) economically realistic carry cost, (b) ISV-bounded by controller, (c) inverse-direction from the pearl's failure mode (pulls policy AWAY from Hold-default, doesn't create exposure-positive bias). Faithful reward modeling, not artificial shaping.
### Files (atomic P0a commit)
15 files / 2316 lines added: 5 new kernels + 9 modifications + 1 mod.rs wire-up. Per `feedback_no_partial_refactor`: all consumers of the new ISV slots wired in this single commit.