feat(sp14-c): aux prediction horizon ISV-driven (multi-bar pivot)

Aux's original label was (p_{t+1} > p_t) — pure HFT-scale microstructure
noise that's unlearnable at our HFT-MFT trading frequency. Migrated to
(p_{t+H} > p_t) where H is read from ISV[AUX_PRED_HORIZON_BARS_INDEX=450].

Adaptive producer drives H from observed avg winning hold time:
- Pearl-A first-observation bootstrap: replace sentinel H=60 directly
  on first valid observation
- Steady-state Wiener-α EMA blend, slow (α=0.01) for stable horizon
  (no target-variance EMA available, fallback per
  pearl_wiener_optimal_adaptive_alpha)
- "No winning trades yet" guard keeps sentinel until first valid observation

Lookahead truncation: labels at t where t+H >= total_bars are masked
(sentinel -1, loss-reduce skips). The existing aux_next_bar_loss_reduce
in aux_heads_kernel.cu already supports the -1 mask convention via the
B_valid count — no new valid_mask parameter needed.

Step 5b finding: Case B — existing per-sample buffers
(hold_at_exit_per_sample, trade_profitable_per_sample) populated by
unified_env_step_core, but no aggregate ISV slot. Added new aggregator
slot AVG_WIN_HOLD_TIME_BARS_INDEX=451 + new producer kernel
avg_win_hold_time_update_kernel.cu (block-tree-reduce, no atomicAdd).
ISV_TOTAL_DIM bumped 450 → 452.

ATOMIC migration per feedback_no_partial_refactor: both label kernels
(aux_sign_label_kernel.cu trajectory + aux_sign_label_per_step_kernel.cu
per-rollout-step) migrated together to the new
(targets, bar_indices, isv, isv_h_idx, out_labels, total, total_bars)
signature. The lookahead host-passed scalar argument is removed; H is
read from ISV inside the kernel (broadcast value, single read per
thread, on-device clamp [1, 240]).

Producer chain (per-epoch boundary): new
GpuDqnTrainer::launch_aux_horizon_chain orchestrates
avg_win_hold_time_update → aux_horizon_update sequentially alongside
launch_kelly_cap_update at the existing epoch-boundary slot in
training_loop.rs.

Trunk math (C.2/C.3/C.4) unchanged — separate aux trunk is label-
agnostic. Validation in C.10 will use H=60 cold-start; the adaptive
producer drives H from real winning-trade observations.

Tests (8 oracle, 5 new + 3 preserved):
- aux_trunk_forward_matches_numpy_reference (C.3) ✓
- aux_trunk_backward_gradient_check (C.4) ✓
- aux_trunk_backward_does_not_write_dx (C.4) ✓
- aux_sign_label_h_bar_horizon (NEW) ✓
- aux_sign_label_lookahead_mask (NEW) ✓
- aux_horizon_pearl_a_bootstrap (NEW) ✓
- aux_horizon_converges_to_steady_target (NEW) ✓
- aux_horizon_holds_sentinel_with_no_winning_trades (NEW) ✓

8/8 pass on RTX 3050 Ti.

Phase C.4b of SP14 Layer C separate-aux-trunk refactor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-05-08 01:37:39 +02:00
parent 5d584dc751
commit 3b71d21834
14 changed files with 2523 additions and 29 deletions

View File

@@ -767,6 +767,25 @@ fn main() {
// producer chain has fresh labels every rollout step. Same per-thread
// O(1) map; same lookahead window semantics; same skip sentinel.
"aux_sign_label_per_step_kernel.cu",
// SP14 Layer C Phase C.4b (2026-05-08): adaptive aux prediction
// horizon producer. Single-thread kernel writing
// `ISV[AUX_PRED_HORIZON_BARS_INDEX=450]` from
// `ISV[AVG_WIN_HOLD_TIME_BARS_INDEX=451]`. Pearl-A first-observation
// bootstrap (sentinel 60.0 → direct replacement); Wiener-α slow
// EMA blend thereafter (α=0.01 fixed; no target-variance EMA
// available in C.4b). Per-epoch boundary launch — H is
// slow-moving. Plan: docs/superpowers/plans/2026-05-07-sp14-layer-c-separate-aux-trunk.md §C.4b.
"aux_horizon_update_kernel.cu",
// SP14 Layer C Phase C.4b (2026-05-08): avg winning hold time
// EMA producer. Single-block kernel that sweeps the per-epoch
// `hold_at_exit_per_sample` + `trade_profitable_per_sample`
// buffers (populated by `unified_env_step_core` in
// `experience_kernels.cu`) and writes the EMA-blended mean to
// `ISV[AVG_WIN_HOLD_TIME_BARS_INDEX=451]` for downstream
// consumption by `aux_horizon_update_kernel`. Block-tree-reduce
// (no atomicAdd) per `feedback_no_atomicadd.md`. Pearl-A
// first-observation bootstrap; α=0.05 EMA thereafter. Plan: §C.4b.
"avg_win_hold_time_update_kernel.cu",
// SP14 Layer B Task B.3 (2026-05-05): Earned Gradient Flow
// producer kernel. Per-step computes the K=4↔K=2 mapped argmax
// mismatch between the Q-head's 4-way direction action

View File

@@ -0,0 +1,118 @@
/* ══════════════════════════════════════════════════════════════════════════
* SP14 Layer C Phase C.4b — adaptive aux prediction horizon producer
* (2026-05-08).
*
* Drives `ISV[AUX_PRED_HORIZON_BARS_INDEX=450]` (the H consumed by the
* aux next-bar sign label kernels) from observed avg winning hold time
* `ISV[AVG_WIN_HOLD_TIME_BARS_INDEX=451]` (produced upstream by
* `avg_win_hold_time_update_kernel`).
*
* Algorithm (per `pearl_first_observation_bootstrap.md` +
* `pearl_wiener_optimal_adaptive_alpha.md`):
*
* 1. If `h_target <= 0` → no winning trade observed yet. Keep
* sentinel H=60.0 (≈1hr @ 1-min bars). This is the cold-start guard
* per `pearl_cold_start_exit_signal_or.md` — without it, a sentinel
* `h_target == 0.0` would clamp to h_min=1 on the bootstrap branch.
*
* 2. If `|h_current - sentinel_h| < 1e-6f` → first valid observation.
* REPLACE H with `clamp(h_target, h_min, h_max)` directly (Pearl-A
* first-observation bootstrap). NO blend — the sentinel must not
* contaminate the EMA.
*
* 3. Otherwise → steady-state Wiener-α EMA blend. Since no variance
* EMA exists for the target signal, falls back to fixed slow blend
* α=0.01 (per `pearl_wiener_optimal_adaptive_alpha.md` fallback).
* H is slow-moving (avg winning hold time changes on epoch scale,
* not step scale), so a slow EMA is appropriate — would otherwise
* track noise.
*
* Pearls + invariants:
*
* - `pearl_no_host_branches_in_captured_graph.md` — single-thread
* kernel (one block, one thread). The launcher in `gpu_aux_trunk.rs`
* pre-loads the `CudaFunction` once at construction; no runtime
* branch on host. Per-epoch boundary launch (horizon is slow-moving;
* per-step would be wasteful).
*
* - `feedback_no_atomicadd.md` — single-thread compute, no atomics.
*
* - `feedback_isv_for_adaptive_bounds.md` — `h_min=1, h_max=240` are
* fundamental floors/ceilings (cannot predict the past; rollout
* buffer guard), NOT tuning parameters. Acceptable as constants.
*
* - `feedback_no_stubs.md` — full body, no placeholder return.
*
* Args:
* isv — `[ISV_TOTAL_DIM]` mapped-pinned f32, modified
* in place at index `isv_h_idx`.
* isv_h_idx — AUX_PRED_HORIZON_BARS_INDEX (450).
* isv_h_target_idx — AVG_WIN_HOLD_TIME_BARS_INDEX (451) — read-only
* source slot.
* isv_h_target_var_idx — variance EMA slot for the target signal, or
* `-1` if no variance EMA exists (uses fixed
* α=0.01). Currently `-1` — Phase C.4b does
* not introduce a target-variance EMA.
* h_min, h_max — fundamental bounds (1.0, 240.0).
* sentinel_h — sentinel value (60.0) used to detect first
* observation. Must match
* `SENTINEL_AUX_PRED_HORIZON_BARS` in
* `sp14_isv_slots.rs`.
* ══════════════════════════════════════════════════════════════════════════ */
extern "C" __global__
void aux_horizon_update(
float* __restrict__ isv,
int isv_h_idx,
int isv_h_target_idx,
int isv_h_target_var_idx, /* -1 if not available */
float h_min,
float h_max,
float sentinel_h)
{
/* Single-thread kernel: only thread (0, 0) does the compute. The
* extra threads early-return so the launch dim can stay (1, 1, 1)
* without branching on the host side. */
if (blockIdx.x != 0 || threadIdx.x != 0) return;
float h_target = isv[isv_h_target_idx];
float h_current = isv[isv_h_idx];
/* Guard 1: no winning trade observed yet. Keep sentinel. */
if (h_target <= 0.0f) {
isv[isv_h_idx] = sentinel_h;
return;
}
/* Guard 2: Pearl-A first-observation bootstrap. The current value is
* exactly the sentinel ⇒ this is the first valid h_target observation;
* REPLACE directly (no blend) per
* `pearl_first_observation_bootstrap.md`. */
if (fabsf(h_current - sentinel_h) < 1e-6f) {
float h_clamped = fmaxf(h_min, fminf(h_max, h_target));
isv[isv_h_idx] = h_clamped;
return;
}
/* Steady-state EMA blend. */
float alpha;
if (isv_h_target_var_idx >= 0) {
/* Wiener-α: α = sample_var / (sample_var + diff_var + ε).
* Bounded into [0.001, 0.1] so a single noisy sample can never
* fully overwrite the EMA, and a perfectly steady target can
* still adapt. Currently unreachable — Phase C.4b passes -1. */
float sample_var = isv[isv_h_target_var_idx];
float diff = h_target - h_current;
float diff_var = diff * diff; /* single-sample approximation */
alpha = sample_var / (sample_var + diff_var + 1e-6f);
alpha = fmaxf(0.001f, fminf(0.1f, alpha));
} else {
/* No variance EMA available — fixed slow blend. The horizon
* tracks avg winning hold time which itself is an EMA, so a
* second-order slow blend (α=0.01) suffices to filter
* remaining sample-to-sample noise. */
alpha = 0.01f;
}
float h_blended = (1.0f - alpha) * h_current + alpha * h_target;
isv[isv_h_idx] = fmaxf(h_min, fminf(h_max, h_blended));
}

View File

@@ -1,6 +1,16 @@
/* ══════════════════════════════════════════════════════════════════════════
* SP13 Layer B — Commit B1.1b producer kernel for aux next-bar sign label
* (2026-05-05).
* (2026-05-05). SP14 Layer C Phase C.4b (2026-05-08): migrated from
* fixed-`lookahead` HFT-scale label `(p_{t+1} > p_t)` to ISV-driven
* multi-bar horizon `(p_{t+H} > p_t)` where H is read from
* `ISV[isv_h_idx = AUX_PRED_HORIZON_BARS_INDEX]` (slot 450).
*
* Rationale: aux's original next-bar prediction is HFT-scale
* microstructure noise — unlearnable at our HFT-MFT trading frequency
* (multi-bar holds). With H read from ISV, the adaptive producer
* (`aux_horizon_update_kernel`) drives H from observed avg winning hold
* time so the label semantic matches actual position lifetime. See
* `2026-05-07-sp14-layer-c-separate-aux-trunk.md` §C.4b for the design.
*
* Replaces the B0 `alloc_zeros::<i32>(total)` placeholder for
* `aux_sign_labels` in `GpuExperienceCollector::collect_experiences_gpu`.
@@ -13,17 +23,21 @@
* Per-thread O(1) map: read `targets[bar*6+2]` (raw_close column — see
* `dt_kernels.cu:803` and `scripted_policy_kernel.cu:56` for the canonical
* column layout: 0=preproc_close, 1=preproc_next, 2=raw_close, 3=raw_next,
* 4=raw_open, 5=mid_open) at `bar` and `bar+lookahead`, classify by
* 4=raw_open, 5=mid_open) at `bar` and `bar+H`, classify by
* strict greater-than — flat/down both map to label 0 because the K=2
* softmax can only distinguish "up" from "not-up". The skip sentinel -1
* fires when the lookahead window would run past `total_bars`; the CE
* loss reduce kernel masks it (`label < 0` → row excluded from
* mean + B_valid count per `aux_heads_kernel.cu`).
*
* H clamping: `H = clamp(round(isv[isv_h_idx]), 1, 240)` — fundamental
* floor (cannot predict the past) and ceiling (rollout buffer guard).
* Clamping is on-device per `feedback_isv_for_adaptive_bounds.md`.
*
* Pure map; no atomicAdd, no reduction, no shared memory — block
* tree-reduce N/A here per `feedback_no_atomicadd.md`. GPU-only data
* read/write per `feedback_cpu_is_read_only.md`. Inputs are mapped-pinned
* (targets) or device-allocated (bar_indices, out_labels) per
* (targets, isv) or device-allocated (bar_indices, out_labels) per
* `feedback_no_htod_htoh_only_mapped_pinned.md`.
*
* Launch config: grid=(ceil(total / 256)), block=(256). Mirrors
@@ -38,36 +52,43 @@
* experience (constructed by host in
* `collect_experiences_gpu`'s pre-existing
* `bar_indices_pinned` fill loop).
* isv — `[ISV_TOTAL_DIM]` mapped-pinned f32. The kernel reads
* `isv[isv_h_idx]` once per thread (broadcast value).
* isv_h_idx — index of the adaptive horizon slot
* (AUX_PRED_HORIZON_BARS_INDEX = 450).
* out_labels — `[total]` device i32; receives -1/0/1 per experience.
* total — number of experiences.
* total_bars — total bars in the data series (for skip-sentinel
* bound check: bar+lookahead >= total_bars ⇒ -1).
* lookahead — number of bars to look ahead (structural design
* constant, value passed in by the host launcher;
* must match the trainer's expectation since the K=2
* head's "up" label semantics encode the same
* lookahead window).
* bound check: bar+H >= total_bars ⇒ -1).
* ══════════════════════════════════════════════════════════════════════════ */
extern "C" __global__
void aux_sign_label_kernel(
const float* __restrict__ targets,
const int* __restrict__ bar_indices,
const float* __restrict__ isv,
int isv_h_idx,
int* __restrict__ out_labels,
int total,
int total_bars,
int lookahead)
int total_bars)
{
int tid = blockIdx.x * blockDim.x + threadIdx.x;
if (tid >= total) return;
/* Read H from ISV (broadcast value — every thread reads the same
* cell). Round-to-nearest then clamp into [1, 240] per
* `feedback_isv_for_adaptive_bounds.md`. */
int H = (int)__float2int_rn(isv[isv_h_idx]);
if (H < 1) H = 1;
if (H > 240) H = 240;
int bar = bar_indices[tid];
if (bar < 0 || bar + lookahead >= total_bars) {
if (bar < 0 || bar + H >= total_bars) {
out_labels[tid] = -1; // skip sentinel — no lookahead window available
return;
}
float p_now = targets[bar * 6 + 2];
float p_fut = targets[(bar + lookahead) * 6 + 2];
float p_fut = targets[(bar + H) * 6 + 2];
// Strict greater-than tie-break: ties (p_fut == p_now, e.g. flat)
// map to "down"=0 because the K=2 head can only distinguish "up"

View File

@@ -1,6 +1,11 @@
/* ══════════════════════════════════════════════════════════════════════════
* SP14 β-migration step 3 — per-rollout-step variant of aux_sign_label_kernel
* (2026-05-07).
* (2026-05-07). SP14 Layer C Phase C.4b (2026-05-08): migrated to
* ISV-driven multi-bar horizon `(p_{t+H} > p_t)` where H is read from
* `ISV[isv_h_idx = AUX_PRED_HORIZON_BARS_INDEX]` (slot 450). Migration
* MUST happen atomically with the trajectory kernel
* (`aux_sign_label_kernel.cu`) per `feedback_no_partial_refactor.md` —
* if only one is migrated, training-time and eval-time labels diverge.
*
* The full collector-end `aux_sign_label_kernel` (this directory) writes
* the entire trajectory's labels at the end of `collect_experiences_gpu`
@@ -12,14 +17,17 @@
* Same per-thread O(1) map as the trajectory kernel: read `targets[bar*6+2]`
* (raw_close column — see `dt_kernels.cu:803` for the canonical column
* layout: 0=preproc_close, 1=preproc_next, 2=raw_close, 3=raw_next,
* 4=raw_open, 5=mid_open) at `bar` and `bar+lookahead`, classify by
* 4=raw_open, 5=mid_open) at `bar` and `bar+H`, classify by
* strict greater-than. Skip sentinel -1 fires when the lookahead window
* runs past `total_bars`.
*
* H clamping: `H = clamp(round(isv[isv_h_idx]), 1, 240)` — same as
* trajectory variant per `feedback_isv_for_adaptive_bounds.md`.
*
* Pure map; no atomicAdd, no reduction, no shared memory — block
* tree-reduce N/A here per `feedback_no_atomicadd.md`. GPU-only data
* read/write per `feedback_cpu_is_read_only.md`. Inputs are mapped-pinned
* (targets) or device-allocated (episode_starts, out_labels) per
* (targets, isv) or device-allocated (episode_starts, out_labels) per
* `feedback_no_htod_htoh_only_mapped_pinned.md`.
*
* Launch config: grid=(ceil(n_episodes / 256)), block=(256). Mirrors the
@@ -29,34 +37,39 @@
* targets — `[total_bars, 6]` mapped-pinned f32.
* episode_starts — `[n_episodes]` device i32, base bar index per env.
* t — current rollout step (0..timesteps).
* isv — `[ISV_TOTAL_DIM]` mapped-pinned f32.
* isv_h_idx — AUX_PRED_HORIZON_BARS_INDEX (450).
* out_labels — `[n_episodes]` device i32; receives -1/0/1 per env.
* n_episodes — number of envs in this rollout slice.
* total_bars — total bars in the data series.
* lookahead — bars to look ahead (must match the trajectory
* kernel's lookahead — both encode the same K=2
* "up" boundary).
* ══════════════════════════════════════════════════════════════════════════ */
extern "C" __global__
void aux_sign_label_per_step_kernel(
const float* __restrict__ targets,
const int* __restrict__ episode_starts,
int t,
const float* __restrict__ isv,
int isv_h_idx,
int* __restrict__ out_labels,
int n_episodes,
int total_bars,
int lookahead)
int total_bars)
{
int ep = blockIdx.x * blockDim.x + threadIdx.x;
if (ep >= n_episodes) return;
/* Read H from ISV (broadcast value); clamp [1, 240]. */
int H = (int)__float2int_rn(isv[isv_h_idx]);
if (H < 1) H = 1;
if (H > 240) H = 240;
int bar = episode_starts[ep] + t;
if (bar < 0 || bar + lookahead >= total_bars) {
if (bar < 0 || bar + H >= total_bars) {
out_labels[ep] = -1; // skip sentinel — no lookahead window available
return;
}
float p_now = targets[bar * 6 + 2];
float p_fut = targets[(bar + lookahead) * 6 + 2];
float p_fut = targets[(bar + H) * 6 + 2];
// Strict greater-than tie-break: matches trajectory kernel — flat/down
// both map to "down"=0 because the K=2 head can only distinguish

View File

@@ -0,0 +1,137 @@
/* ══════════════════════════════════════════════════════════════════════════
* SP14 Layer C Phase C.4b — avg winning hold time EMA producer (2026-05-08).
*
* Aggregates per-sample trade-close events from `experience_kernels.cu`:
*
* - `hold_at_exit_per_sample[N*L]` — `saved_hold_time` on
* exit/reverse, else 0.0
* - `trade_profitable_per_sample[N*L]` — 1 iff (trade_close && step_ret>0),
* else 0
*
* Computes mean(hold_at_exit | profitable) over the per-epoch sample
* buffer, then EMA-blends into `ISV[AVG_WIN_HOLD_TIME_BARS_INDEX=451]`
* for downstream consumption by `aux_horizon_update_kernel`.
*
* Algorithm (Pearl-A first-observation bootstrap +
* `pearl_wiener_optimal_adaptive_alpha.md`):
*
* Phase 1 (block-tree-reduce): each block of `BLK_DIM` threads sweeps
* its tile of N=B*T samples, summing `hold_at_exit_per_sample[i]` and
* counting `trade_profitable_per_sample[i]==1`. Local block reduce in
* shared memory; block-0-thread-0 finalises after grid sync (single
* block — see launch config below).
*
* Phase 2 (Pearl-A + EMA): if sample mean is invalid (count==0), keep
* ISV slot unchanged. If current ISV value is sentinel 0.0 (or near
* 0.0), REPLACE with sample mean (Pearl-A first-observation bootstrap
* per `pearl_first_observation_bootstrap.md`). Otherwise EMA-blend
* with α=0.05 (slow blend — avg winning hold time is a per-epoch
* aggregate that varies on policy-evolution timescales, faster than
* the horizon EMA but still slow enough to filter
* sample-distribution noise).
*
* Pearls + invariants:
*
* - `feedback_no_atomicadd.md` — single-block kernel (the per-epoch
* sample buffer is ≤ N*L≈64k slots; one block of 256 threads sweeps
* it via stride-based for-loop, block-tree-reduce in shmem, no
* atomics across blocks).
*
* - `pearl_no_host_branches_in_captured_graph.md` — pre-loaded
* `CudaFunction` at construction; no per-launch host branches. The
* guard for `count == 0` is on-device.
*
* - `pearl_first_observation_bootstrap.md` — first valid sample
* replaces sentinel directly; no blend.
*
* - `feedback_no_stubs.md` — full body, no return-zero placeholders.
*
* Args:
* hold_at_exit_per_sample — `[total_samples]` device f32.
* trade_profitable_per_sample — `[total_samples]` device i32.
* total_samples — N*L (B*T total samples in the epoch).
* isv — `[ISV_TOTAL_DIM]` device f32, modified
* in place at index `isv_target_idx`.
* isv_target_idx — AVG_WIN_HOLD_TIME_BARS_INDEX (451).
* sentinel — SENTINEL_AVG_WIN_HOLD_TIME_BARS (0.0).
* alpha — EMA blend rate (0.05 — slow).
*
* Launch: grid=(1, 1, 1), block=(BLK_DIM=256, 1, 1).
* Shared memory: 2 * BLK_DIM floats (sum_hold + count_profitable). The
* count is summed as float for unified shmem layout — values are
* bounded by total_samples ≤ 2^16 so f32 representation is exact for
* counts up to 2^24.
* ══════════════════════════════════════════════════════════════════════════ */
#define BLK_DIM 256
extern "C" __global__
void avg_win_hold_time_update(
const float* __restrict__ hold_at_exit_per_sample,
const int* __restrict__ trade_profitable_per_sample,
int total_samples,
float* __restrict__ isv,
int isv_target_idx,
float sentinel,
float alpha)
{
/* Single-block kernel — only block 0 does anything. */
if (blockIdx.x != 0) return;
extern __shared__ float shmem[];
float* sh_sum = shmem; /* [BLK_DIM] */
float* sh_count = shmem + BLK_DIM; /* [BLK_DIM] */
int tid = threadIdx.x;
/* Phase 1a: stride-based sweep — accumulate sum + count locally. */
float local_sum = 0.0f;
float local_count = 0.0f;
for (int i = tid; i < total_samples; i += BLK_DIM) {
int profit = trade_profitable_per_sample[i]; /* 0 or 1 */
float hold = hold_at_exit_per_sample[i]; /* 0 if not exit/reverse */
if (profit != 0) {
local_sum += hold;
local_count += 1.0f;
}
}
sh_sum[tid] = local_sum;
sh_count[tid] = local_count;
__syncthreads();
/* Phase 1b: block-tree-reduce (no atomicAdd) — halve every iter. */
for (int s = BLK_DIM >> 1; s > 0; s >>= 1) {
if (tid < s) {
sh_sum[tid] += sh_sum[tid + s];
sh_count[tid] += sh_count[tid + s];
}
__syncthreads();
}
/* Phase 2: thread 0 finalises EMA. */
if (tid != 0) return;
float total_sum = sh_sum[0];
float total_count = sh_count[0];
/* Guard: no winning trades observed this epoch — keep ISV unchanged. */
if (total_count <= 0.0f) {
return;
}
float sample_mean = total_sum / total_count;
float current = isv[isv_target_idx];
/* Pearl-A first-observation bootstrap. The sentinel is exactly 0.0;
* any value within 1e-6 is treated as sentinel. (`current <= 0.0f`
* is a stronger condition that also handles any out-of-spec
* negative writes.) */
if (current <= 1e-6f) {
isv[isv_target_idx] = sample_mean;
return;
}
/* Steady-state EMA blend. */
float blended = (1.0f - alpha) * current + alpha * sample_mean;
isv[isv_target_idx] = blended;
}

View File

@@ -50,7 +50,10 @@ use cudarc::driver::{CudaFunction, CudaStream, LaunchConfig, PushKernelArg};
use crate::MLError;
use super::gpu_dqn_trainer::{AUX_TRUNK_BACKWARD_CUBIN, AUX_TRUNK_FORWARD_CUBIN};
use super::gpu_dqn_trainer::{
AUX_HORIZON_UPDATE_CUBIN, AUX_TRUNK_BACKWARD_CUBIN, AUX_TRUNK_FORWARD_CUBIN,
AVG_WIN_HOLD_TIME_UPDATE_CUBIN,
};
/// Hidden width of the aux trunk's first internal layer (Linear_1 → ELU
/// output). Mirrors the encoder output dimension so Layer-1 acts as a
@@ -450,3 +453,167 @@ impl AuxTrunkBackwardOps {
Ok(())
}
}
/// SP14 Layer C Phase C.4b (2026-05-08): adaptive aux prediction horizon
/// producer.
///
/// Drives `ISV[AUX_PRED_HORIZON_BARS_INDEX=450]` from
/// `ISV[AVG_WIN_HOLD_TIME_BARS_INDEX=451]` via Pearl-A first-observation
/// bootstrap + slow Wiener-α EMA. Single-thread, single-block kernel —
/// horizon is slow-moving so per-epoch boundary launch is appropriate
/// (per-step would be wasteful and would track sample noise).
///
/// # Pearls applied
/// - `pearl_first_observation_bootstrap.md` — sentinel = 60.0; first
/// valid observation REPLACES (no blend) so the cold-start sentinel
/// never contaminates the EMA.
/// - `pearl_wiener_optimal_adaptive_alpha.md` — fixed α=0.01 fallback
/// when no target-variance EMA exists.
/// - `pearl_no_host_branches_in_captured_graph.md` — `CudaFunction`
/// pre-loaded once at construction.
/// - `feedback_isv_for_adaptive_bounds.md` — h_min=1, h_max=240 are
/// fundamental floors/ceilings, not tuning parameters.
#[allow(missing_debug_implementations)]
pub(crate) struct AuxHorizonUpdateOps {
update_kernel: CudaFunction,
}
impl AuxHorizonUpdateOps {
pub(crate) fn new(stream: &Arc<CudaStream>) -> Result<Self, MLError> {
let context = stream.context();
let module = context
.load_cubin(AUX_HORIZON_UPDATE_CUBIN.to_vec())
.map_err(|e| MLError::ModelError(format!("aux_horizon_update cubin load: {e}")))?;
let update_kernel = module
.load_function("aux_horizon_update")
.map_err(|e| MLError::ModelError(format!("aux_horizon_update load: {e}")))?;
Ok(Self { update_kernel })
}
/// Launch the horizon producer.
///
/// Args:
/// - `isv_ptr`: ISV[ISV_TOTAL_DIM] device pointer (mapped-pinned).
/// - `isv_h_idx`: AUX_PRED_HORIZON_BARS_INDEX (450).
/// - `isv_h_target_idx`: AVG_WIN_HOLD_TIME_BARS_INDEX (451).
/// - `isv_h_target_var_idx`: variance EMA slot for target, or `-1`
/// if none (uses fixed α=0.01).
/// - `h_min`, `h_max`: fundamental bounds (1.0, 240.0).
/// - `sentinel_h`: SENTINEL_AUX_PRED_HORIZON_BARS (60.0).
#[allow(clippy::too_many_arguments)]
pub(crate) fn launch(
&self,
stream: &Arc<CudaStream>,
isv_ptr: u64,
isv_h_idx: i32,
isv_h_target_idx: i32,
isv_h_target_var_idx: i32,
h_min: f32,
h_max: f32,
sentinel_h: f32,
) -> Result<(), MLError> {
unsafe {
stream
.launch_builder(&self.update_kernel)
.arg(&isv_ptr)
.arg(&isv_h_idx)
.arg(&isv_h_target_idx)
.arg(&isv_h_target_var_idx)
.arg(&h_min)
.arg(&h_max)
.arg(&sentinel_h)
.launch(LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (1, 1, 1),
shared_mem_bytes: 0,
})
.map_err(|e| MLError::ModelError(format!("aux_horizon_update: {e}")))?;
}
Ok(())
}
}
/// SP14 Layer C Phase C.4b (2026-05-08): avg winning hold time EMA producer.
///
/// Sweeps the per-epoch `hold_at_exit_per_sample` +
/// `trade_profitable_per_sample` buffers (populated by
/// `unified_env_step_core` in `experience_kernels.cu`) and writes the
/// EMA-blended mean to `ISV[AVG_WIN_HOLD_TIME_BARS_INDEX=451]` for
/// downstream consumption by `AuxHorizonUpdateOps`.
///
/// Single-block kernel; block-tree-reduce over the per-sample buffer.
/// Pearl-A first-observation bootstrap; α=0.05 EMA thereafter.
///
/// # Pearls applied
/// - `feedback_no_atomicadd.md` — single block, block-tree-reduce in shmem.
/// - `pearl_first_observation_bootstrap.md` — sentinel = 0.0 → REPLACE.
/// - `pearl_no_host_branches_in_captured_graph.md` — `CudaFunction`
/// pre-loaded at construction; on-device `count == 0` guard.
#[allow(missing_debug_implementations)]
pub(crate) struct AvgWinHoldTimeUpdateOps {
update_kernel: CudaFunction,
}
impl AvgWinHoldTimeUpdateOps {
/// Block dim used by the producer kernel — must match `BLK_DIM` in
/// `avg_win_hold_time_update_kernel.cu`.
const BLK_DIM: u32 = 256;
/// Steady-state EMA blend rate. Slow enough to filter
/// sample-distribution noise, fast enough to track policy evolution
/// across epochs.
pub(crate) const ALPHA: f32 = 0.05;
pub(crate) fn new(stream: &Arc<CudaStream>) -> Result<Self, MLError> {
let context = stream.context();
let module = context
.load_cubin(AVG_WIN_HOLD_TIME_UPDATE_CUBIN.to_vec())
.map_err(|e| MLError::ModelError(format!("avg_win_hold_time_update cubin load: {e}")))?;
let update_kernel = module
.load_function("avg_win_hold_time_update")
.map_err(|e| MLError::ModelError(format!("avg_win_hold_time_update load: {e}")))?;
Ok(Self { update_kernel })
}
/// Launch the producer.
///
/// Args:
/// - `hold_at_exit_ptr`: f32 device ptr `[total_samples]`.
/// - `trade_profitable_ptr`: i32 device ptr `[total_samples]`.
/// - `total_samples`: N*L (B*T total samples this epoch).
/// - `isv_ptr`: ISV[ISV_TOTAL_DIM] device pointer.
/// - `isv_target_idx`: AVG_WIN_HOLD_TIME_BARS_INDEX (451).
/// - `sentinel`: SENTINEL_AVG_WIN_HOLD_TIME_BARS (0.0).
/// - `alpha`: EMA blend rate (use `Self::ALPHA = 0.05`).
#[allow(clippy::too_many_arguments)]
pub(crate) fn launch(
&self,
stream: &Arc<CudaStream>,
hold_at_exit_ptr: u64,
trade_profitable_ptr: u64,
total_samples: i32,
isv_ptr: u64,
isv_target_idx: i32,
sentinel: f32,
alpha: f32,
) -> Result<(), MLError> {
let smem_bytes = 2 * Self::BLK_DIM * std::mem::size_of::<f32>() as u32;
unsafe {
stream
.launch_builder(&self.update_kernel)
.arg(&hold_at_exit_ptr)
.arg(&trade_profitable_ptr)
.arg(&total_samples)
.arg(&isv_ptr)
.arg(&isv_target_idx)
.arg(&sentinel)
.arg(&alpha)
.launch(LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (Self::BLK_DIM, 1, 1),
shared_mem_bytes: smem_bytes,
})
.map_err(|e| MLError::ModelError(format!("avg_win_hold_time_update: {e}")))?;
}
Ok(())
}
}

View File

@@ -2082,6 +2082,23 @@ pub(crate) static AUX_TRUNK_FORWARD_CUBIN: &[u8] = include_bytes!(concat!(env!("
#[allow(dead_code)]
pub(crate) static AUX_TRUNK_BACKWARD_CUBIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/aux_trunk_backward_kernel.cubin"));
/// SP14 Layer C Phase C.4b (2026-05-08): adaptive aux prediction horizon
/// producer cubin. Single-thread kernel that drives
/// `ISV[AUX_PRED_HORIZON_BARS_INDEX=450]` from
/// `ISV[AVG_WIN_HOLD_TIME_BARS_INDEX=451]` via Pearl-A first-observation
/// bootstrap + slow Wiener-α EMA. Loaded by
/// `gpu_aux_trunk::AuxHorizonUpdateOps`. Per-epoch boundary launch. See
/// `aux_horizon_update_kernel.cu` for the algorithm + plan §C.4b.
pub(crate) static AUX_HORIZON_UPDATE_CUBIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/aux_horizon_update_kernel.cubin"));
/// SP14 Layer C Phase C.4b (2026-05-08): avg winning hold time EMA
/// producer cubin. Single-block kernel that sweeps the per-epoch
/// `hold_at_exit_per_sample` + `trade_profitable_per_sample` buffers and
/// writes `ISV[AVG_WIN_HOLD_TIME_BARS_INDEX=451]` for downstream
/// consumption by `aux_horizon_update_kernel`. Loaded by
/// `gpu_aux_trunk::AvgWinHoldTimeUpdateOps`. Per-epoch boundary launch.
pub(crate) static AVG_WIN_HOLD_TIME_UPDATE_CUBIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/avg_win_hold_time_update_kernel.cubin"));
/// Plan C Phase 2 follow-up A.2 (2026-04-29): q-drift rate ISV producer.
/// Single-thread single-block cold-path kernel mirroring
/// `moe_lambda_eff_kernel.cu` / `kelly_cap_update_kernel.cu`. Reads
@@ -2399,7 +2416,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 = 450; // SP5 + Layer D D1+D2+D3 + SP7 + SP8 + SP9 + SP10 + SP11 + SP13 + SP13 v3 + SP14 (post-C.1) + SP15 + SP14-C aux trunk control plane. Bumped 444 → 450 by SP14 Layer C Phase C.1 (2026-05-08): atomically deleted 9 α-machinery slots in [385..396) AND added 6 aux-trunk control-plane slots [444..450) — AUX_TRUNK_LR (444) + AUX_TRUNK_BETA1 (445) + AUX_TRUNK_BETA2 (446) + AUX_TRUNK_EPS (447) + AUX_TRUNK_GRAD_CLIP (448) + H_S2_AUX_RMS_EMA (449). Per `feedback_no_legacy_aliases` and `feedback_no_partial_refactor`. Deleted slots left as RESERVED gap (NOT compacted) to preserve checkpoint layout fingerprint compatibility — the surviving SP14 q_disagreement_* diagnostic slots (383, 384, 389) keep their original indices. Pre-C.1 base: 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]; SP14 q_disagreement diagnostic at [383..385), [389] — Q_DISAGREEMENT_SHORT/LONG_EMA + Q_DISAGREEMENT_VARIANCE_EMA (slots [385..389) and [390..396) RESERVED gap from C.1 deletion); SP15 [397..444) per Phase 1; SP14-C [444..450) per Phase C.1. Intentional 5-slot boundary gap at [367..372))
pub(crate) const ISV_TOTAL_DIM: usize = 452; // SP5 + Layer D D1+D2+D3 + SP7 + SP8 + SP9 + SP10 + SP11 + SP13 + SP13 v3 + SP14 (post-C.1) + SP15 + SP14-C aux trunk control plane + SP14-C Phase C.4b aux horizon. Bumped 450 → 452 by SP14 Layer C Phase C.4b (2026-05-08): added 2 aux-prediction-horizon slots [450..452) — AUX_PRED_HORIZON_BARS (450) + AVG_WIN_HOLD_TIME_BARS (451). Aux's original label was (p_{t+1} > p_t), pure HFT-scale microstructure noise — pivoted to (p_{t+H} > p_t) with H read from ISV[450], adaptively driven from observed avg winning hold time via Pearl-A bootstrap + Wiener-α EMA. Case B: existing `hold_at_exit_per_sample` + `trade_profitable_per_sample` per-sample buffers, no aggregate slot — added new aggregate slot 451 to expose the EMA target. Bumped 444 → 450 by SP14 Layer C Phase C.1 (2026-05-08): atomically deleted 9 α-machinery slots in [385..396) AND added 6 aux-trunk control-plane slots [444..450) — AUX_TRUNK_LR (444) + AUX_TRUNK_BETA1 (445) + AUX_TRUNK_BETA2 (446) + AUX_TRUNK_EPS (447) + AUX_TRUNK_GRAD_CLIP (448) + H_S2_AUX_RMS_EMA (449). Per `feedback_no_legacy_aliases` and `feedback_no_partial_refactor`. Deleted slots left as RESERVED gap (NOT compacted) to preserve checkpoint layout fingerprint compatibility — the surviving SP14 q_disagreement_* diagnostic slots (383, 384, 389) keep their original indices. Pre-C.1 base: 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]; SP14 q_disagreement diagnostic at [383..385), [389] — Q_DISAGREEMENT_SHORT/LONG_EMA + Q_DISAGREEMENT_VARIANCE_EMA (slots [385..389) and [390..396) RESERVED gap from C.1 deletion); SP15 [397..444) per Phase 1; SP14-C [444..450) per Phase C.1. 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).
@@ -7336,6 +7353,20 @@ pub struct GpuDqnTrainer {
#[allow(dead_code)]
aux_trunk_backward_ops: super::gpu_aux_trunk::AuxTrunkBackwardOps,
/// SP14 Layer C Phase C.4b (2026-05-08): adaptive aux prediction
/// horizon producer. Drives `ISV[AUX_PRED_HORIZON_BARS_INDEX=450]`
/// from `ISV[AVG_WIN_HOLD_TIME_BARS_INDEX=451]` via Pearl-A
/// first-observation bootstrap + slow Wiener-α EMA. Single-thread
/// kernel; per-epoch boundary launch. See `aux_horizon_update_kernel.cu`.
aux_horizon_update_ops: super::gpu_aux_trunk::AuxHorizonUpdateOps,
/// SP14 Layer C Phase C.4b (2026-05-08): avg winning hold time EMA
/// producer. Sweeps the per-epoch `hold_at_exit_per_sample` +
/// `trade_profitable_per_sample` buffers and writes
/// `ISV[AVG_WIN_HOLD_TIME_BARS_INDEX=451]`. Block-tree-reduce
/// (no atomicAdd); per-epoch boundary launch.
avg_win_hold_time_update_ops: super::gpu_aux_trunk::AvgWinHoldTimeUpdateOps,
// ── Speculative inference cache ──
/// Cached trunk output [B, SH2] from between-bar speculative forward.
speculative_h_s2: CudaSlice<f32>,
@@ -14049,6 +14080,75 @@ impl GpuDqnTrainer {
Ok(())
}
/// SP14 Layer C Phase C.4b (2026-05-08): launch the aux prediction
/// horizon producer chain at per-epoch boundary.
///
/// Two sequential single-launch kernels:
/// 1. `avg_win_hold_time_update` — sweeps per-sample
/// `hold_at_exit_per_sample` + `trade_profitable_per_sample`,
/// block-tree-reduces winning-trade hold-time mean, EMA-blends
/// into `ISV[AVG_WIN_HOLD_TIME_BARS_INDEX=451]` (Pearl-A
/// bootstrap then α=0.05).
/// 2. `aux_horizon_update` — single-thread Pearl-A bootstrap +
/// slow Wiener-α (α=0.01 fixed) drives
/// `ISV[AUX_PRED_HORIZON_BARS_INDEX=450]` from the slot above.
///
/// Per-epoch boundary launch — both signals are slow-moving and
/// per-step launches would track sample noise. Caller passes the
/// per-sample buffer pointers + length from the collector's
/// `hold_at_exit_per_sample` / `trade_profitable_per_sample` slabs.
///
/// Pearls applied:
/// - `pearl_first_observation_bootstrap.md`
/// - `pearl_wiener_optimal_adaptive_alpha.md`
/// - `pearl_no_host_branches_in_captured_graph.md`
/// - `feedback_no_atomicadd.md` (block-tree-reduce in shmem only).
pub(crate) fn launch_aux_horizon_chain(
&self,
hold_at_exit_dev_ptr: u64,
trade_profitable_dev_ptr: u64,
total_samples: i32,
) -> Result<(), MLError> {
use crate::cuda_pipeline::sp14_isv_slots::{
AUX_PRED_HORIZON_BARS_INDEX, AUX_PRED_HORIZON_BARS_MAX,
AUX_PRED_HORIZON_BARS_MIN, AVG_WIN_HOLD_TIME_BARS_INDEX,
SENTINEL_AUX_PRED_HORIZON_BARS, SENTINEL_AVG_WIN_HOLD_TIME_BARS,
};
debug_assert!(
self.isv_signals_dev_ptr != 0,
"launch_aux_horizon_chain: isv_signals_dev_ptr must be allocated"
);
// Step 1: avg winning hold time EMA producer.
self.avg_win_hold_time_update_ops.launch(
&self.stream,
hold_at_exit_dev_ptr,
trade_profitable_dev_ptr,
total_samples,
self.isv_signals_dev_ptr,
AVG_WIN_HOLD_TIME_BARS_INDEX as i32,
SENTINEL_AVG_WIN_HOLD_TIME_BARS,
super::gpu_aux_trunk::AvgWinHoldTimeUpdateOps::ALPHA,
)?;
// Step 2: horizon producer (reads slot 451 from step 1).
// No target-variance EMA in C.4b → pass -1 for var idx (kernel
// falls back to fixed α=0.01).
self.aux_horizon_update_ops.launch(
&self.stream,
self.isv_signals_dev_ptr,
AUX_PRED_HORIZON_BARS_INDEX as i32,
AVG_WIN_HOLD_TIME_BARS_INDEX as i32,
-1,
AUX_PRED_HORIZON_BARS_MIN,
AUX_PRED_HORIZON_BARS_MAX,
SENTINEL_AUX_PRED_HORIZON_BARS,
)?;
Ok(())
}
/// SP8 (Fix 36, 2026-05-03): launch GPU-only `train_active_frac` canary
/// producer.
///
@@ -22200,6 +22300,17 @@ impl GpuDqnTrainer {
let aux_trunk_backward_ops =
super::gpu_aux_trunk::AuxTrunkBackwardOps::new(&stream)?;
// SP14 Layer C Phase C.4b (2026-05-08): aux prediction horizon
// producer + winning-hold-time EMA producer. Both pre-load their
// `CudaFunction` handle at construction per
// `pearl_no_host_branches_in_captured_graph.md`. Per-epoch
// boundary launch (horizon is slow-moving — per-step would track
// sample noise).
let aux_horizon_update_ops =
super::gpu_aux_trunk::AuxHorizonUpdateOps::new(&stream)?;
let avg_win_hold_time_update_ops =
super::gpu_aux_trunk::AvgWinHoldTimeUpdateOps::new(&stream)?;
// ── Speculative inference cache ──
let speculative_h_s2 = stream.alloc_zeros::<f32>(b * sh2)
.map_err(|e| MLError::ModelError(format!("speculative_h_s2 alloc: {e}")))?;
@@ -23004,6 +23115,9 @@ impl GpuDqnTrainer {
aux_trunk_forward_ops,
// SP14 Layer C Phase C.4 (2026-05-08): aux trunk backward orchestrator.
aux_trunk_backward_ops,
// SP14 Layer C Phase C.4b (2026-05-08): aux horizon + winning-hold-time producers.
aux_horizon_update_ops,
avg_win_hold_time_update_ops,
speculative_h_s2,
speculative_features,
speculative_valid: false,

View File

@@ -4090,19 +4090,39 @@ impl GpuExperienceCollector {
.alloc_zeros::<i32>(total)
.map_err(|e| MLError::ModelError(format!("alloc aux_sign_labels: {e}")))?;
{
// SP14 Layer C Phase C.4b (2026-05-08): horizon H is no longer
// a host-passed scalar derived from `config.hindsight_lookahead`
// — it is read from `ISV[AUX_PRED_HORIZON_BARS_INDEX=450]` on
// device. The slot is initialized to sentinel 60.0 by the fold-
// boundary registry and adaptively driven by
// `aux_horizon_update_kernel` from observed avg winning hold
// time. See plan §C.4b.
//
// The collector's `isv_signals_dev_ptr` is set by the trainer
// post-construction; if NULL (uncommon — should be set before
// any aux label launch in production), the kernel would read
// garbage. Assert non-zero.
use crate::cuda_pipeline::sp14_isv_slots::AUX_PRED_HORIZON_BARS_INDEX;
debug_assert!(
self.isv_signals_dev_ptr != 0,
"aux_sign_label_kernel: isv_signals_dev_ptr must be set before \
collect_experiences_gpu — H is read from ISV[450] on device."
);
let total_i32 = total as i32;
let total_bars_i32 = config.total_bars;
let lookahead_i32 = config.hindsight_lookahead.max(1);
let isv_h_idx_i32 = AUX_PRED_HORIZON_BARS_INDEX as i32;
let isv_dev_ptr = self.isv_signals_dev_ptr;
let blocks = ((total + 255) / 256) as u32;
unsafe {
self.stream
.launch_builder(&self.aux_sign_label_kernel)
.arg(&targets_buf.dev_ptr) // const float* targets (mapped pinned dev_ptr)
.arg(&bar_indices_gpu_ptr) // const int* bar_indices (mapped pinned dev_ptr)
.arg(&isv_dev_ptr) // const float* isv
.arg(&isv_h_idx_i32) // int isv_h_idx
.arg(&aux_sign_labels) // int* out_labels
.arg(&total_i32) // int total
.arg(&total_bars_i32) // int total_bars
.arg(&lookahead_i32) // int lookahead
.launch(LaunchConfig {
grid_dim: (blocks, 1, 1),
block_dim: (256, 1, 1),
@@ -4563,7 +4583,16 @@ impl GpuExperienceCollector {
// `aux_sign_label_kernel` launch (line ~3776 below) so
// both encode the same K=2 "up vs not-up" boundary.
{
let lookahead_i32 = config.hindsight_lookahead.max(1);
// SP14 Layer C Phase C.4b (2026-05-08): H is read from
// ISV[AUX_PRED_HORIZON_BARS_INDEX=450] on device,
// mirrors trajectory kernel migration. See plan §C.4b.
use crate::cuda_pipeline::sp14_isv_slots::AUX_PRED_HORIZON_BARS_INDEX;
debug_assert!(
self.isv_signals_dev_ptr != 0,
"aux_sign_label_per_step_kernel: isv_signals_dev_ptr must be set."
);
let isv_h_idx_i32 = AUX_PRED_HORIZON_BARS_INDEX as i32;
let isv_dev_ptr = self.isv_signals_dev_ptr;
let labels_dev = self.exp_aux_nb_label_buf.raw_ptr();
let targets_dev = targets_buf.dev_ptr;
let starts_dev = self.episode_starts_buf.raw_ptr();
@@ -4575,10 +4604,11 @@ impl GpuExperienceCollector {
.arg(&targets_dev)
.arg(&starts_dev)
.arg(&t_i32)
.arg(&isv_dev_ptr)
.arg(&isv_h_idx_i32)
.arg(&labels_dev)
.arg(&n_i32)
.arg(&total_bars)
.arg(&lookahead_i32)
.launch(LaunchConfig {
grid_dim: (blocks, 1, 1),
block_dim: (256, 1, 1),
@@ -5798,6 +5828,26 @@ impl GpuExperienceCollector {
ptr
}
/// SP14 Layer C Phase C.4b (2026-05-08): raw device pointer to
/// `hold_at_exit_per_sample` `[alloc_episodes * alloc_timesteps]` —
/// `saved_hold_time` on exit/reverse, else 0.0. Consumed by
/// `avg_win_hold_time_update_kernel` at the per-epoch boundary to
/// compute the EMA of winning-trade hold time.
pub fn hold_at_exit_per_sample_dev_ptr(&self) -> u64 {
let (ptr, _guard) = self.hold_at_exit_per_sample.device_ptr(&self.stream);
ptr
}
/// SP14 Layer C Phase C.4b (2026-05-08): raw device pointer to
/// `trade_profitable_per_sample` `[alloc_episodes * alloc_timesteps]`
/// — 1 iff (trade_close && step_ret > 0). Consumed by
/// `avg_win_hold_time_update_kernel` to mask the hold-time mean to
/// winning trades only.
pub fn trade_profitable_per_sample_dev_ptr(&self) -> u64 {
let (ptr, _guard) = self.trade_profitable_per_sample.device_ptr(&self.stream);
ptr
}
/// Last experience count from the most recent `collect_experiences_gpu` call.
pub fn last_experience_count(&self) -> usize { self.last_experience_count }

View File

@@ -71,6 +71,40 @@ pub const SENTINEL_H_S2_AUX_RMS: f32 = 0.0; // Pearl-A bootstrap
pub const SP14_C_SLOT_BASE: usize = 444;
pub const SP14_C_SLOT_END: usize = 450;
// ── SP14 Layer C Phase C.4b — aux prediction horizon (added 2026-05-08) ──
// Two slots driving the new ISV-driven aux label horizon:
//
// - AUX_PRED_HORIZON_BARS_INDEX (450): adaptive horizon H read by both
// `aux_sign_label_kernel.cu` and `aux_sign_label_per_step_kernel.cu`
// to compute label = (p[bar+H] > p[bar]). FoldReset sentinel
// SENTINEL_AUX_PRED_HORIZON_BARS=60.0 (≈1hr @ 1-min bars). Pearl-A
// first-observation bootstrap on first valid AVG_WIN_HOLD_TIME_BARS
// observation; Wiener-α EMA blend thereafter (slow α=0.01 — no
// variance EMA available for the target).
//
// - AVG_WIN_HOLD_TIME_BARS_INDEX (451): aggregate EMA of winning-trade
// hold time (bars) read by the horizon producer. Computed by
// `avg_win_hold_time_update_kernel.cu` from the per-sample buffers
// `hold_at_exit_per_sample` + `trade_profitable_per_sample` populated
// by `unified_env_step_core` in `experience_kernels.cu`. FoldReset
// sentinel SENTINEL_AVG_WIN_HOLD_TIME_BARS=0.0 (Pearl-A bootstrap).
//
// Plan: docs/superpowers/plans/2026-05-07-sp14-layer-c-separate-aux-trunk.md §C.4b
// Case B (Step 5b): existing trade-close infrastructure has per-sample
// `hold_at_exit_per_sample[N*L]` + `trade_profitable_per_sample[N*L]`,
// but no aggregate slot — added new slot 451 to expose the EMA.
pub const AUX_PRED_HORIZON_BARS_INDEX: usize = 450;
pub const AVG_WIN_HOLD_TIME_BARS_INDEX: usize = 451;
// ── Aux horizon producer constants ────────────────────────────────────
pub const SENTINEL_AUX_PRED_HORIZON_BARS: f32 = 60.0; // Pearl-A bootstrap
pub const SENTINEL_AVG_WIN_HOLD_TIME_BARS: f32 = 0.0; // Pearl-A bootstrap
pub const AUX_PRED_HORIZON_BARS_MIN: f32 = 1.0; // floor (cannot predict past)
pub const AUX_PRED_HORIZON_BARS_MAX: f32 = 240.0; // ceiling (rollout buffer guard)
pub const SP14_C4B_SLOT_BASE: usize = 450;
pub const SP14_C4B_SLOT_END: usize = 452;
#[cfg(test)]
mod tests {
use super::*;
@@ -125,4 +159,26 @@ mod tests {
SP14_C_SLOT_END, ISV_TOTAL_DIM,
);
}
/// Lock SP14 Layer C Phase C.4b aux-prediction-horizon slot layout.
/// Two contiguous slots [450..452) — adaptive horizon H + winning
/// hold-time EMA target.
#[test]
fn sp14_c4b_aux_horizon_slot_layout_locked() {
assert_eq!(SP14_C4B_SLOT_BASE, 450);
assert_eq!(SP14_C4B_SLOT_END, 452);
assert_eq!(AUX_PRED_HORIZON_BARS_INDEX, 450);
assert_eq!(AVG_WIN_HOLD_TIME_BARS_INDEX, 451);
}
#[test]
fn all_sp14_c4b_slots_fit_within_isv_total_dim() {
use crate::cuda_pipeline::gpu_dqn_trainer::ISV_TOTAL_DIM;
assert!(
SP14_C4B_SLOT_END <= ISV_TOTAL_DIM,
"SP14_C4B_SLOT_END={} exceeds ISV_TOTAL_DIM={} — bus too small for SP14 Layer C Phase C.4b aux horizon slots; \
bump ISV_TOTAL_DIM in gpu_dqn_trainer.rs (and update layout_fingerprint_seed()).",
SP14_C4B_SLOT_END, ISV_TOTAL_DIM,
);
}
}

View File

@@ -999,6 +999,28 @@ impl StateResetRegistry {
category: ResetCategory::FoldReset,
description: "ISV[H_S2_AUX_RMS_EMA_INDEX=449] — SP14 Layer C aux-trunk output RMS EMA (mirrors GRN trunk's H_S2_RMS_EMA_INDEX=96 for the new aux trunk's `h_s2_aux` output). Stateful kernel output produced in Phase C.6 by `h_s2_aux_rms_ema_kernel`. FoldReset sentinel SENTINEL_H_S2_AUX_RMS=0.0 — Pearl-A first-observation bootstrap per `pearl_first_observation_bootstrap.md`. Plan: docs/superpowers/plans/2026-05-07-sp14-layer-c-separate-aux-trunk.md §C.1.",
},
// ── SP14 Layer C Phase C.4b (2026-05-08): aux prediction horizon ──
// Two slots [450, 451] driving the new ISV-driven aux label
// horizon. AUX_PRED_HORIZON_BARS_INDEX=450 is the H consumed by
// both `aux_sign_label_kernel.cu` and
// `aux_sign_label_per_step_kernel.cu`; sentinel 60.0 (≈1hr @
// 1-min bars) is the cold-start default until the adaptive
// producer (`aux_horizon_update_kernel`) lands the first
// observation. AVG_WIN_HOLD_TIME_BARS_INDEX=451 is the EMA
// target produced by `avg_win_hold_time_update_kernel` from
// the existing per-sample `hold_at_exit_per_sample` +
// `trade_profitable_per_sample` buffers; sentinel 0.0 is the
// Pearl-A bootstrap value (no winning trades observed yet).
RegistryEntry {
name: "sp14_c4b_aux_pred_horizon_bars",
category: ResetCategory::FoldReset,
description: "ISV[AUX_PRED_HORIZON_BARS_INDEX=450] — SP14 Layer C Phase C.4b adaptive aux prediction horizon H. Read by `aux_sign_label_kernel.cu` + `aux_sign_label_per_step_kernel.cu` to compute label = (p[bar+H] > p[bar]). FoldReset sentinel SENTINEL_AUX_PRED_HORIZON_BARS=60.0 (≈1hr @ 1-min bars). Pearl-A first-observation bootstrap on first valid winning-trade observation; Wiener-α slow EMA blend (α=0.01 fixed) thereafter via `aux_horizon_update_kernel`. Plan: docs/superpowers/plans/2026-05-07-sp14-layer-c-separate-aux-trunk.md §C.4b.",
},
RegistryEntry {
name: "sp14_c4b_avg_win_hold_time_bars",
category: ResetCategory::FoldReset,
description: "ISV[AVG_WIN_HOLD_TIME_BARS_INDEX=451] — SP14 Layer C Phase C.4b avg winning hold time EMA (bars). Aggregated from `hold_at_exit_per_sample` + `trade_profitable_per_sample` per-sample buffers (populated by `unified_env_step_core` in `experience_kernels.cu`) by `avg_win_hold_time_update_kernel` at per-epoch boundary. FoldReset sentinel SENTINEL_AVG_WIN_HOLD_TIME_BARS=0.0 — Pearl-A first-observation bootstrap. Drives the horizon producer for ISV[450]. Plan: §C.4b.",
},
// ── SP15 Phase 1.2 (2026-05-06): cost-net sharpe slots ────────────
// Two ISV slots [407, 408]:
// - OFI_IMPACT_LAMBDA_INDEX=407: Invariant-1 anchor (NOT a

View File

@@ -391,6 +391,26 @@ impl DQNTrainer {
if let Err(e) = fused.trainer().launch_sp5_pearl_6_kelly(ps_dev_ptr, n_envs) {
tracing::warn!(error = %e, "SP5 Pearl 6 producer launch failed");
}
// SP14 Layer C Phase C.4b (2026-05-08): adaptive aux
// prediction horizon — per-epoch boundary launch chain.
// Step 1 (`avg_win_hold_time_update`) sweeps the
// per-sample `hold_at_exit_per_sample` +
// `trade_profitable_per_sample` buffers (populated by
// `unified_env_step_core`) and writes the winning-trade
// hold-time EMA to ISV[451]. Step 2 (`aux_horizon_update`)
// drives ISV[450] from ISV[451] via Pearl-A bootstrap +
// slow Wiener-α EMA. Both signals are slow-moving;
// per-epoch cadence is appropriate. Plan: §C.4b.
let hold_at_exit_ptr = collector.hold_at_exit_per_sample_dev_ptr();
let trade_profitable_ptr = collector.trade_profitable_per_sample_dev_ptr();
let total_samples = (collector.alloc_episodes() * collector.alloc_timesteps()) as i32;
if let Err(e) = fused.trainer().launch_aux_horizon_chain(
hold_at_exit_ptr,
trade_profitable_ptr,
total_samples,
) {
tracing::warn!(epoch, "launch_aux_horizon_chain failed (non-fatal): {e}");
}
}
// D.8 Plan 2 Task 6C: update TLOB regime focus EMA in ISV at epoch boundary.
@@ -8050,6 +8070,33 @@ impl DQNTrainer {
);
}
}
// SP14 Layer C Phase C.4b (2026-05-08): aux prediction horizon
// sentinels. Both slots use Pearl-A first-observation bootstrap
// — the FoldReset rewrites them to sentinel so the kernels
// detect "first observation" cleanly on the new fold's first
// launch (avoids cross-fold EMA contamination).
"sp14_c4b_aux_pred_horizon_bars" => {
if let Some(ref fused) = self.fused_ctx {
use crate::cuda_pipeline::sp14_isv_slots::{
AUX_PRED_HORIZON_BARS_INDEX, SENTINEL_AUX_PRED_HORIZON_BARS,
};
fused.trainer().write_isv_signal_at(
AUX_PRED_HORIZON_BARS_INDEX,
SENTINEL_AUX_PRED_HORIZON_BARS,
);
}
}
"sp14_c4b_avg_win_hold_time_bars" => {
if let Some(ref fused) = self.fused_ctx {
use crate::cuda_pipeline::sp14_isv_slots::{
AVG_WIN_HOLD_TIME_BARS_INDEX, SENTINEL_AVG_WIN_HOLD_TIME_BARS,
};
fused.trainer().write_isv_signal_at(
AVG_WIN_HOLD_TIME_BARS_INDEX,
SENTINEL_AVG_WIN_HOLD_TIME_BARS,
);
}
}
// SP15 Phase 1.2 (2026-05-06): cost-net sharpe slots.
// OFI_IMPACT_LAMBDA_INDEX=407 is an Invariant-1 anchor (NOT
// a stateful EMA) — rewrite the constructor's value at fold

View File

@@ -735,4 +735,434 @@ mod gpu {
kernel_path.display()
);
}
// ════════════════════════════════════════════════════════════════════
// SP14 Layer C Phase C.4b oracle tests (2026-05-08).
//
// Added 4 tests exercising the new ISV-driven aux prediction horizon:
//
// - aux_sign_label_h_bar_horizon — label correctness for a known H
// - aux_sign_label_lookahead_mask — masking when t+H runs past total_bars
// - aux_horizon_pearl_a_bootstrap — sentinel REPLACED, not blended
// - aux_horizon_converges_to_steady_target — Wiener-α EMA convergence
// - aux_horizon_holds_sentinel_with_no_winning_trades — cold-start guard
//
// Plan: docs/superpowers/plans/2026-05-07-sp14-layer-c-separate-aux-trunk.md §C.4b
// ════════════════════════════════════════════════════════════════════
const AUX_SIGN_LABEL_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/aux_sign_label_kernel.cubin"));
const AUX_HORIZON_UPDATE_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/aux_horizon_update_kernel.cubin"));
/// AUX_PRED_HORIZON_BARS_INDEX — must match `sp14_isv_slots.rs`.
const AUX_PRED_HORIZON_BARS_INDEX: usize = 450;
/// AVG_WIN_HOLD_TIME_BARS_INDEX — must match `sp14_isv_slots.rs`.
const AVG_WIN_HOLD_TIME_BARS_INDEX: usize = 451;
/// SENTINEL_AUX_PRED_HORIZON_BARS — must match `sp14_isv_slots.rs`.
const SENTINEL_H: f32 = 60.0;
/// AUX_PRED_HORIZON_BARS_MIN/MAX — fundamental bounds.
const H_MIN: f32 = 1.0;
const H_MAX: f32 = 240.0;
/// ISV size used by the horizon producer test (must be > 451).
const ISV_TEST_SIZE: usize = 460;
fn load_aux_sign_label(stream: &Arc<CudaStream>) -> CudaFunction {
let module = stream
.context()
.load_cubin(AUX_SIGN_LABEL_CUBIN.to_vec())
.expect("load aux_sign_label cubin");
module
.load_function("aux_sign_label_kernel")
.expect("load aux_sign_label_kernel function")
}
fn load_aux_horizon_update(stream: &Arc<CudaStream>) -> CudaFunction {
let module = stream
.context()
.load_cubin(AUX_HORIZON_UPDATE_CUBIN.to_vec())
.expect("load aux_horizon_update cubin");
module
.load_function("aux_horizon_update")
.expect("load aux_horizon_update function")
}
/// Helper: launch `aux_sign_label_kernel` on the given pinned-mapped
/// targets / bar_indices / isv buffers. Returns the device-side
/// labels copied back to host.
fn run_aux_sign_label(
stream: &Arc<CudaStream>,
kernel: &CudaFunction,
targets_buf: &MappedF32Buffer,
bar_indices_buf: &MappedF32Buffer,
isv_buf: &MappedF32Buffer,
total: usize,
total_bars: i32,
) -> Vec<i32> {
let labels_dev = stream
.alloc_zeros::<i32>(total)
.expect("alloc out_labels");
let total_i32 = total as i32;
let isv_h_idx_i32 = AUX_PRED_HORIZON_BARS_INDEX as i32;
let blocks = ((total + 255) / 256) as u32;
let targets_dev = targets_buf.dev_ptr;
let bar_indices_dev = bar_indices_buf.dev_ptr;
let isv_dev = isv_buf.dev_ptr;
unsafe {
stream
.launch_builder(kernel)
.arg(&targets_dev)
.arg(&bar_indices_dev)
.arg(&isv_dev)
.arg(&isv_h_idx_i32)
.arg(&labels_dev)
.arg(&total_i32)
.arg(&total_bars)
.launch(LaunchConfig {
grid_dim: (blocks, 1, 1),
block_dim: (256, 1, 1),
shared_mem_bytes: 0,
})
.expect("aux_sign_label_kernel launch");
}
let mut out = vec![0_i32; total];
stream.memcpy_dtoh(&labels_dev, &mut out).expect("dtoh labels");
stream.synchronize().expect("sync");
out
}
/// Helper: build a synthetic `targets [total_bars, 6]` mapped-pinned
/// buffer with `prices` written into column 2 (raw_close). All other
/// columns are zero-filled.
fn build_targets_buf(prices: &[f32]) -> MappedF32Buffer {
let total_bars = prices.len();
let buf = unsafe { MappedF32Buffer::new(total_bars * 6) }
.expect("MappedF32Buffer::new(targets)");
let host = unsafe { std::slice::from_raw_parts_mut(buf.host_ptr, total_bars * 6) };
host.fill(0.0);
for (i, &p) in prices.iter().enumerate() {
host[i * 6 + 2] = p;
}
buf
}
/// Helper: build a `bar_indices [total]` mapped-pinned buffer with
/// the i32 values reinterpreted as f32 (MappedF32Buffer is our only
/// generic mapped-pinned allocator). The kernel reads it as `int*`
/// — bit-pattern preservation matters more than f32 representability.
fn build_bar_indices_buf(indices: &[i32]) -> MappedF32Buffer {
let n = indices.len();
let buf = unsafe { MappedF32Buffer::new(n) }
.expect("MappedF32Buffer::new(bar_indices)");
// bytewise copy: same memory layout, no f32 interpretation
unsafe {
let dst = buf.host_ptr as *mut i32;
std::ptr::copy_nonoverlapping(indices.as_ptr(), dst, n);
}
buf
}
/// Helper: build a `[ISV_TEST_SIZE]` mapped-pinned ISV buffer with
/// AUX_PRED_HORIZON_BARS_INDEX seeded to `h` and other slots zero.
fn build_isv_buf(h: f32) -> MappedF32Buffer {
let buf = unsafe { MappedF32Buffer::new(ISV_TEST_SIZE) }
.expect("MappedF32Buffer::new(isv)");
let host = unsafe { std::slice::from_raw_parts_mut(buf.host_ptr, ISV_TEST_SIZE) };
host.fill(0.0);
host[AUX_PRED_HORIZON_BARS_INDEX] = h;
buf
}
/// Step 6 oracle: aux_sign_label produces correct labels for a
/// known H on three deterministic price series.
///
/// 1. Linearly increasing prices, H=30, T=200 → labels 1 in
/// `[0..170)` and -1 in `[170..200)` (lookahead mask).
/// 2. Linearly decreasing prices, H=30, T=200 → labels 0 in
/// `[0..170)` and -1 in `[170..200)`.
/// 3. Sine wave, H=30 → analytic comparison of `p[t+30] > p[t]`.
#[test]
#[ignore = "requires GPU"]
fn aux_sign_label_h_bar_horizon() {
let stream = make_test_stream();
let kernel = load_aux_sign_label(&stream);
const T: usize = 200;
const H: i32 = 30;
let total_bars = T as i32;
let bar_indices: Vec<i32> = (0..T as i32).collect();
let bar_indices_buf = build_bar_indices_buf(&bar_indices);
let isv_buf = build_isv_buf(H as f32);
// Case 1: linearly increasing
let prices_up: Vec<f32> = (0..T).map(|i| i as f32).collect();
let targets_up = build_targets_buf(&prices_up);
let labels_up =
run_aux_sign_label(&stream, &kernel, &targets_up, &bar_indices_buf, &isv_buf, T, total_bars);
let lookahead_cutoff = T as i32 - H;
for (i, &lbl) in labels_up.iter().enumerate() {
let i_i32 = i as i32;
if i_i32 + H < total_bars {
assert_eq!(lbl, 1, "increasing series: bar {i} expected 1 (p[t+H]>p[t]), got {lbl}");
} else {
assert_eq!(lbl, -1, "increasing series: bar {i} expected mask -1 (t+H>=total_bars), got {lbl}");
}
}
eprintln!("Case 1 (increasing): valid bars={lookahead_cutoff}, all 1 — OK");
// Case 2: linearly decreasing
let prices_dn: Vec<f32> = (0..T).map(|i| (T - i) as f32).collect();
let targets_dn = build_targets_buf(&prices_dn);
let labels_dn =
run_aux_sign_label(&stream, &kernel, &targets_dn, &bar_indices_buf, &isv_buf, T, total_bars);
for (i, &lbl) in labels_dn.iter().enumerate() {
let i_i32 = i as i32;
if i_i32 + H < total_bars {
assert_eq!(lbl, 0, "decreasing series: bar {i} expected 0 (p[t+H]<p[t]), got {lbl}");
} else {
assert_eq!(lbl, -1, "decreasing series: bar {i} expected mask -1, got {lbl}");
}
}
eprintln!("Case 2 (decreasing): valid bars={lookahead_cutoff}, all 0 — OK");
// Case 3: sine wave — analytic comparison
let prices_sin: Vec<f32> =
(0..T).map(|i| (i as f32 * 0.1).sin()).collect();
let targets_sin = build_targets_buf(&prices_sin);
let labels_sin =
run_aux_sign_label(&stream, &kernel, &targets_sin, &bar_indices_buf, &isv_buf, T, total_bars);
for (i, &lbl) in labels_sin.iter().enumerate() {
let i_i32 = i as i32;
if i_i32 + H >= total_bars {
assert_eq!(lbl, -1, "sine: bar {i} mask expected -1, got {lbl}");
continue;
}
let expected = if prices_sin[i + H as usize] > prices_sin[i] { 1 } else { 0 };
assert_eq!(lbl, expected,
"sine: bar {i} expected {expected} (p[{}]={:.4} vs p[{i}]={:.4}), got {lbl}",
i + H as usize, prices_sin[i + H as usize], prices_sin[i]);
}
eprintln!("Case 3 (sine): valid bars={lookahead_cutoff}, all match analytic — OK");
}
/// Step 7 oracle: lookahead mask invariant — for H=60 with
/// total_bars=100, labels in `[40..100)` must be -1 and labels in
/// `[0..40)` must be ∈ {0, 1} (valid).
#[test]
#[ignore = "requires GPU"]
fn aux_sign_label_lookahead_mask() {
let stream = make_test_stream();
let kernel = load_aux_sign_label(&stream);
const T: usize = 100;
const H: i32 = 60;
let total_bars = T as i32;
let bar_indices: Vec<i32> = (0..T as i32).collect();
let bar_indices_buf = build_bar_indices_buf(&bar_indices);
let isv_buf = build_isv_buf(H as f32);
let prices: Vec<f32> = (0..T).map(|i| (i as f32 * 0.5).sin()).collect();
let targets = build_targets_buf(&prices);
let labels =
run_aux_sign_label(&stream, &kernel, &targets, &bar_indices_buf, &isv_buf, T, total_bars);
for (i, &lbl) in labels.iter().enumerate() {
let i_i32 = i as i32;
if i_i32 + H >= total_bars {
assert_eq!(lbl, -1,
"lookahead mask: bar {i} (t+H={}) >= total_bars={total_bars} expected -1, got {lbl}",
i_i32 + H);
} else {
assert!(lbl == 0 || lbl == 1,
"valid window: bar {i} expected ∈ {{0, 1}}, got {lbl}");
}
}
let mask_count = labels.iter().filter(|&&l| l == -1).count();
let valid_count = labels.iter().filter(|&&l| l == 0 || l == 1).count();
assert_eq!(mask_count, H as usize,
"expected {} masked bars (last H rows), got {mask_count}", H);
assert_eq!(valid_count, T - H as usize,
"expected {} valid bars, got {valid_count}", T - H as usize);
eprintln!("lookahead_mask: {mask_count}/{T} masked, {valid_count}/{T} valid — OK");
}
/// Helper: launch `aux_horizon_update` once on the given mapped-pinned
/// ISV buffer.
fn run_aux_horizon_update(
stream: &Arc<CudaStream>,
kernel: &CudaFunction,
isv_buf: &MappedF32Buffer,
isv_h_idx: i32,
isv_h_target_idx: i32,
isv_h_target_var_idx: i32,
h_min: f32,
h_max: f32,
sentinel_h: f32,
) {
let isv_dev = isv_buf.dev_ptr;
unsafe {
stream
.launch_builder(kernel)
.arg(&isv_dev)
.arg(&isv_h_idx)
.arg(&isv_h_target_idx)
.arg(&isv_h_target_var_idx)
.arg(&h_min)
.arg(&h_max)
.arg(&sentinel_h)
.launch(LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (1, 1, 1),
shared_mem_bytes: 0,
})
.expect("aux_horizon_update launch");
}
stream.synchronize().expect("sync");
}
/// Step 7b oracle: Pearl-A first-observation bootstrap — sentinel
/// must be REPLACED, not blended.
#[test]
#[ignore = "requires GPU"]
fn aux_horizon_pearl_a_bootstrap() {
let stream = make_test_stream();
let kernel = load_aux_horizon_update(&stream);
// 1. Sentinel state: H=60.0, h_target=90.0 (first valid observation).
let isv_buf = build_isv_buf(SENTINEL_H);
let host = unsafe { std::slice::from_raw_parts_mut(isv_buf.host_ptr, ISV_TEST_SIZE) };
host[AVG_WIN_HOLD_TIME_BARS_INDEX] = 90.0;
run_aux_horizon_update(
&stream,
&kernel,
&isv_buf,
AUX_PRED_HORIZON_BARS_INDEX as i32,
AVG_WIN_HOLD_TIME_BARS_INDEX as i32,
-1, // no variance EMA → fixed α
H_MIN,
H_MAX,
SENTINEL_H,
);
let h_after_bootstrap = host[AUX_PRED_HORIZON_BARS_INDEX];
assert!(
(h_after_bootstrap - 90.0).abs() < 1e-3,
"Pearl-A bootstrap: expected H=90.0 (REPLACED, not blended), got {h_after_bootstrap}",
);
eprintln!("Pearl-A bootstrap: H={SENTINEL_H} sentinel + target=90.0 → H={h_after_bootstrap} (REPLACED)");
// 2. Subsequent observation: target=100.0 → blended (NOT replaced).
// Current H is now 90.0, NOT sentinel — kernel takes the EMA path.
host[AVG_WIN_HOLD_TIME_BARS_INDEX] = 100.0;
run_aux_horizon_update(
&stream,
&kernel,
&isv_buf,
AUX_PRED_HORIZON_BARS_INDEX as i32,
AVG_WIN_HOLD_TIME_BARS_INDEX as i32,
-1,
H_MIN,
H_MAX,
SENTINEL_H,
);
let h_after_blend = host[AUX_PRED_HORIZON_BARS_INDEX];
// α=0.01 → blend = 0.99*90 + 0.01*100 = 90.1 ± float noise
let expected = 0.99_f32 * 90.0 + 0.01_f32 * 100.0;
assert!(
(h_after_blend - expected).abs() < 1e-3,
"second observation: expected blended H≈{expected}, got {h_after_blend} \
(must be in (90, 100) — NOT replaced)",
);
assert!(
h_after_blend > 90.0 && h_after_blend < 100.0,
"blended H={h_after_blend} not in (90, 100) — should be small step from 90 toward 100",
);
eprintln!("Second obs: H={h_after_blend} (blended toward 100, expected ≈{expected})");
}
/// Step 7c oracle: Wiener-α EMA convergence — with α=0.01 fixed
/// (no variance EMA), H starting at 50.0 with steady target=100.0
/// should converge per the geometric `(1-α)^k * 50 + (1-(1-α)^k) * 100`.
#[test]
#[ignore = "requires GPU"]
fn aux_horizon_converges_to_steady_target() {
let stream = make_test_stream();
let kernel = load_aux_horizon_update(&stream);
// Post-bootstrap state: H=50.0, target=100.0 (steady).
// 50.0 differs from sentinel 60.0 → bootstrap path NOT triggered.
let isv_buf = build_isv_buf(50.0);
let host = unsafe { std::slice::from_raw_parts_mut(isv_buf.host_ptr, ISV_TEST_SIZE) };
host[AVG_WIN_HOLD_TIME_BARS_INDEX] = 100.0;
const ITERS: usize = 100;
for _ in 0..ITERS {
run_aux_horizon_update(
&stream,
&kernel,
&isv_buf,
AUX_PRED_HORIZON_BARS_INDEX as i32,
AVG_WIN_HOLD_TIME_BARS_INDEX as i32,
-1,
H_MIN,
H_MAX,
SENTINEL_H,
);
}
let h_final = host[AUX_PRED_HORIZON_BARS_INDEX];
// Closed-form for fixed-α geometric blend:
// H_k = (1-α)^k * H_0 + (1 - (1-α)^k) * target
// α=0.01, k=100, H_0=50, target=100
// (1-α)^100 ≈ 0.366032
// H_100 ≈ 0.366 * 50 + 0.634 * 100 ≈ 81.7
let alpha: f32 = 0.01;
let one_minus_alpha = 1.0 - alpha;
let decay = one_minus_alpha.powi(ITERS as i32);
let expected = decay * 50.0 + (1.0 - decay) * 100.0;
let tol = 0.05; // accumulated f32 noise across 100 iters
assert!(
(h_final - expected).abs() < tol,
"Wiener-α convergence: expected H≈{expected:.4} after {ITERS} iters, got {h_final:.4} \
(decay=(1-{alpha})^{ITERS}≈{decay:.6})",
);
eprintln!("Convergence: H_0=50, target=100, α={alpha}, after {ITERS} iters → H={h_final:.4} (expected {expected:.4})");
}
/// Step 7d oracle: cold-start "no winning trades" guard — when
/// `h_target == 0.0` (no winning trade observed yet), the kernel
/// MUST keep the sentinel and not blend toward zero.
#[test]
#[ignore = "requires GPU"]
fn aux_horizon_holds_sentinel_with_no_winning_trades() {
let stream = make_test_stream();
let kernel = load_aux_horizon_update(&stream);
// Sentinel state: H=60.0, target=0.0 (no winning trade yet).
let isv_buf = build_isv_buf(SENTINEL_H);
let host = unsafe { std::slice::from_raw_parts_mut(isv_buf.host_ptr, ISV_TEST_SIZE) };
host[AVG_WIN_HOLD_TIME_BARS_INDEX] = 0.0;
run_aux_horizon_update(
&stream,
&kernel,
&isv_buf,
AUX_PRED_HORIZON_BARS_INDEX as i32,
AVG_WIN_HOLD_TIME_BARS_INDEX as i32,
-1,
H_MIN,
H_MAX,
SENTINEL_H,
);
let h_after = host[AUX_PRED_HORIZON_BARS_INDEX];
assert!(
(h_after - SENTINEL_H).abs() < 1e-6,
"no winning trades guard: expected H=sentinel {SENTINEL_H} unchanged, got {h_after}",
);
eprintln!("No-winning-trades guard: H={h_after} (sentinel preserved)");
}
}

View File

@@ -7254,3 +7254,60 @@ The error surfaces as "load sp15_dd_state cubin: ILLEGAL_ADDRESS" via sticky-cas
- Pre-init wiring (this commit): `crates/ml/src/trainers/dqn/trainer/training_loop.rs:1716`, immediately before `self.gpu_experience_collector = Some(collector);`.
- Per-epoch re-wiring (preserved): `crates/ml/src/trainers/dqn/trainer/training_loop.rs:855-957`, inside the `for epoch in 0..self.hyperparams.epochs` loop.
- Producer setters: `GpuExperienceCollector::set_isv_signals_ptr` / `set_sp15_alpha_warm_count_ptr` / `set_sp15_plasticity_target`; `GpuReplayBuffer::set_isv_signals_ptr` / `set_sp15_dd_trajectory_per_env_ptr` / `set_sp15_per_env_dims`.
## SP14 Layer C Phase C.4b — aux label horizon ISV-driven (multi-bar pivot, 2026-05-08)
**Rationale.** Aux's original label was `(p_{t+1} > p_t)` — pure HFT-scale microstructure noise that's unlearnable at our HFT-MFT trading frequency (multi-bar holds). Without this fix, aux flatlines at ln(2) regardless of trunk separation: the *target itself* is unlearnable. Pivoted to `(p_{t+H} > p_t)` where H is read from `ISV[AUX_PRED_HORIZON_BARS_INDEX=450]` and adaptively driven from observed avg winning hold time.
**Step 5b finding (Case B).** Searched `crates/ml/src/cuda_pipeline/*.cu` + `*.rs` for existing avg-winning-hold-time infrastructure:
- ✅ Per-sample buffers exist: `hold_at_exit_per_sample [N*L]` (`saved_hold_time` on exit/reverse, populated by `unified_env_step_core` in `experience_kernels.cu:2698`) + `trade_profitable_per_sample [N*L]` (1 iff trade_close && step_ret>0, `experience_kernels.cu:2727`).
- ❌ No aggregate ISV slot tracking the EMA. Existing host-side reduction in `trail_fire_and_hold_per_mag` is per-magnitude HEALTH_DIAG output, not a controller signal.
- ⇒ **Case B**: added `AVG_WIN_HOLD_TIME_BARS_INDEX=451` aggregate slot + new `avg_win_hold_time_update_kernel.cu` producer that block-tree-reduces winning-trade hold-time mean and EMA-blends into the slot. ISV_TOTAL_DIM bumped 450 → **452** (one extra over plan's 451 estimate).
**Slot layout (added 2026-05-08):**
| Slot | Name | Sentinel | Producer | Consumer(s) |
|------|------|----------|----------|-------------|
| 450 | `AUX_PRED_HORIZON_BARS_INDEX` | 60.0 | `aux_horizon_update_kernel` | `aux_sign_label_kernel.cu`, `aux_sign_label_per_step_kernel.cu` |
| 451 | `AVG_WIN_HOLD_TIME_BARS_INDEX` | 0.0 | `avg_win_hold_time_update_kernel` | `aux_horizon_update_kernel` |
Both slots use Pearl-A first-observation bootstrap. Slot 451 has α=0.05 EMA; slot 450 has fixed α=0.01 slow blend (no target-variance EMA available, so Wiener-α reduces to its fallback per `pearl_wiener_optimal_adaptive_alpha.md`).
**Atomic migration (per `feedback_no_partial_refactor`).** Both `aux_sign_label_kernel.cu` (trajectory variant — collector-end) and `aux_sign_label_per_step_kernel.cu` (per-rollout-step variant — used by sp14-β EGF chain) migrated together to the new ISV-driven signature `(targets, bar_indices, isv, isv_h_idx, out_labels, total, total_bars)`. The `lookahead` host-passed scalar argument is removed from both kernels and both launch sites; H is read from ISV inside the kernel (broadcast value, single ISV read per thread). If only one had been migrated, training-time labels (trajectory) and eval-time per-step labels would diverge — fatal inconsistency.
**Lookahead masking (correctness-critical).** Both kernels preserve the existing `-1` skip sentinel for `bar + H >= total_bars`. The downstream loss-reduce kernel `aux_next_bar_loss_reduce` already supports `-1` masking natively (`aux_heads_kernel.cu:281-336`: rows with `label == -1` contribute 0 to numerator AND 0 to valid_count; backward kernel reads the saved `B_valid` and zeros d_logits for masked rows at line 489-526). No new `valid_mask` parameter required — existing `-1` sentinel convention is sufficient.
**Producer chain (per-epoch boundary).** New launcher method `GpuDqnTrainer::launch_aux_horizon_chain` orchestrates two sequential single-launch kernels at epoch boundary alongside `launch_kelly_cap_update`:
1. `avg_win_hold_time_update` — single-block, block-tree-reduce over `[total_samples]` (no atomicAdd per `feedback_no_atomicadd`); writes `ISV[451]`. Pearl-A bootstrap (sentinel=0); α=0.05 EMA blend thereafter.
2. `aux_horizon_update` — single-thread; reads `ISV[451]`, drives `ISV[450]` via Pearl-A bootstrap (sentinel=60.0) + fixed-α=0.01 slow blend. Bounds `[h_min=1, h_max=240]` are fundamental floors/ceilings (cannot predict past; rollout buffer guard) per `feedback_isv_for_adaptive_bounds`.
**Files modified (this commit):**
- Modify: `crates/ml/src/cuda_pipeline/sp14_isv_slots.rs` (+2 const slot indices, +4 sentinels/bounds, +2 lock tests)
- Modify: `crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs` (ISV_TOTAL_DIM 450→452; +2 cubin static refs; +2 ops fields; +2 ops constructors; +1 `launch_aux_horizon_chain` method)
- Modify: `crates/ml/src/cuda_pipeline/gpu_aux_trunk.rs` (+`AuxHorizonUpdateOps` + `AvgWinHoldTimeUpdateOps` + their `new()` + `launch()`)
- Modify: `crates/ml/src/cuda_pipeline/gpu_experience_collector.rs` (2 launch sites: trajectory + per-step kernel signatures bump; +2 dev-ptr getters for `hold_at_exit_per_sample` + `trade_profitable_per_sample`)
- Modify: `crates/ml/src/cuda_pipeline/aux_sign_label_kernel.cu` (signature: drop `lookahead` arg, add `isv` + `isv_h_idx`; read H on-device with clamp `[1, 240]`)
- Modify: `crates/ml/src/cuda_pipeline/aux_sign_label_per_step_kernel.cu` (same migration)
- Create: `crates/ml/src/cuda_pipeline/aux_horizon_update_kernel.cu`
- Create: `crates/ml/src/cuda_pipeline/avg_win_hold_time_update_kernel.cu`
- Modify: `crates/ml/build.rs` (+2 cubin entries)
- Modify: `crates/ml/src/trainers/dqn/state_reset_registry.rs` (+2 FoldReset entries)
- Modify: `crates/ml/src/trainers/dqn/trainer/training_loop.rs` (+2 reset_named_state dispatch arms; +1 launch site for `launch_aux_horizon_chain` at epoch boundary)
- Modify: `crates/ml/tests/aux_trunk_oracle_tests.rs` (+5 oracle tests)
- Modify: `docs/dqn-wire-up-audit.md` (this section)
**Tests (8 oracle tests in `aux_trunk_oracle_tests` — 3 preserved + 5 new):**
- ✅ `aux_trunk_forward_matches_numpy_reference` (C.3 — preserved)
- ✅ `aux_trunk_backward_gradient_check` (C.4 — preserved)
- ✅ `aux_trunk_backward_does_not_write_dx` (C.4 — preserved)
- ✅ `aux_sign_label_h_bar_horizon` (NEW — 3 deterministic series: increasing/decreasing/sine, H=30, T=200; verifies labels match analytic and tail H bars are masked -1)
- ✅ `aux_sign_label_lookahead_mask` (NEW — H=60, T=100: verifies last H rows are -1 and first T-H rows are valid {0,1})
- ✅ `aux_horizon_pearl_a_bootstrap` (NEW — sentinel 60.0 + first observation 90.0 → REPLACE; second observation 100.0 → blend = 0.99*90 + 0.01*100 = 90.1, in (90,100))
- ✅ `aux_horizon_converges_to_steady_target` (NEW — H_0=50, target=100, α=0.01: closed-form `(1-α)^100 * 50 + (1-(1-α)^100) * 100 = 81.6984` — bit-precise match within 0.05 tol)
- ✅ `aux_horizon_holds_sentinel_with_no_winning_trades` (NEW — sentinel 60.0 + target=0.0 → unchanged 60.0)
8/8 pass on RTX 3050 Ti.
**Open follow-ups:**
- Add a target-variance EMA for `AVG_WIN_HOLD_TIME_BARS_INDEX` (Welford-style, single new ISV slot) in a future phase if the fixed α=0.01 blend is too slow/fast on real-data validation. The producer kernel already has the variance-slot path wired; pass `isv_h_target_var_idx >= 0` to enable Wiener-α.
- Pearl note for `pearl_event_driven_reward_density_alignment.md` cross-reference: this phase aligns the AUX *label density* to event density (winning trade close), the same principle as the SP12 reward-density alignment.

File diff suppressed because it is too large Load Diff