feat(sp20): Phase 3 Task 3.2 — Hold opportunity-cost dual emission (Component 2)

Lands the per-bar Hold opportunity-cost dual emission at
experience_env_step's per-bar branches (positioned-non-event ~3650 +
flat-non-event ~3737), atomically with the trade-close consumer
wiring at the segment_complete branch (~3289 — replaces the Phase 2
Task 2.2 forward-reference placeholder `hold_baseline = 0.0f`) per
`feedback_no_partial_refactor`.

Spec §4.2 dual-emission contract:

  per_bar_opp_cost = -aux_conf × cost_scale
  Path 1 (Hold-only):  r_micro/r_opp_cost += per_bar_opp_cost
                                            - ISV[HOLD_REWARD_EMA]
                       (centered for Q-target stability)
  Path 2 (always):     hold_baseline_buffer[env, t % 30] = per_bar_opp_cost
                       (uncentered, consumed at trade close)

  At trade close:
    hold_baseline = sp20_sum_hold_baseline_over_trade(buf, env, 30,
                                                      current_t,
                                                      segment_hold_time)
    alpha = R_event - hold_baseline    # replaces Phase 2 placeholder

The summation walks backwards `min(30, segment_hold_time)` slots from
`(current_t - 1) % 30` in env i's row of the per-env circular buffer.
The trade-close bar's segment_complete branch does NOT write to the
buffer, so the most recent per-bar write is at current_t - 1.

Layout decision (plan errata Gap 9): per-env stride
`[N_envs × HOLD_BASELINE_BUFFER_SIZE = 30]` row-major. The kernel is
per-env-parallel; a single global ring would interleave bars across
envs and break trade-attribution semantic.

Trade-open tracking decision (plan errata Gap 10): NO new state slot
needed. The existing `segment_hold_time` (= saved_hold_time captured
before PS_HOLD_TIME reset at experience_kernels.cu:2618) plus
current_t suffice — walk backwards in the buffer.

Replaces SP18 D-leg `compute_sp18_hold_opportunity_cost` calls at
experience_kernels.cu:3672 and :3783. The device function in
trade_physics.cuh:655 is RETAINED — still called by
sp18_hold_opp_test_kernel.cu (oracle test surface). Per
`feedback_no_stubs`: only production callers migrate; the helper
stays for the test surface.

New device helpers (sp20_hold_baseline.cuh):
  - sp20_compute_per_bar_aux_conf_k2(logits) — bit-identical to the
    formula in sp20_stats_compute_kernel.cu Pass A.
  - sp20_sum_hold_baseline_over_trade(buf, env, size, current_t,
    segment_hold_time) — walks backwards with modulo wrap-around.

New buffer + reset infrastructure:
  - GpuExperienceCollector.hold_baseline_buffer field
  - HOLD_BASELINE_BUFFER_SIZE = 30 constant (re-exported)
  - StateResetRegistry FoldReset entry + invariant test
  - reset_named_state dispatch arm

Six new kernel args appended to experience_env_step signature:
aux_logits_per_env, hold_baseline_buffer, hold_buffer_size, aux_k,
hold_cost_scale_idx, hold_reward_ema_idx. All NULL-tolerant.

HOLD_REWARD_EMA forward-reference: ISV[516] is updated by
sp20_emas_compute_kernel reading per_bar_hold_reward from
sp20_aggregate_inputs_kernel which currently emits 0.0f as a Phase
3.2 forward-ref placeholder (line 292). The parallel `sp20-phase-2-fix`
agent owns wiring the real producer; Task 3.2's centering math
references the ISV slot (not a hardcoded 0), so when the parallel
branch lands, EMA starts updating and centering becomes load-bearing
automatically. No additional Task 3.2 work needed post-merge.

Tests (sp20_hold_baseline_test.rs, 4 GPU oracle tests):
  1. aux_conf_k2_bounds_and_fixed_points — bounds, uniform/saturated/
     spec-warmup/spec-confident/symmetry fixed points.
  2. sum_hold_baseline_over_trade_indexing — basic indexing,
     hold_time>size clamp, defensive guards, modulo wrap-around.
  3. sum_hold_baseline_per_env_stride — env-stride correctness across
     row-major buffer.
  4. dual_emission_hold_vs_non_hold — full Path 1 + Path 2 contract,
     mirrors plan §3.2 reference fixture.

Verification:
  SQLX_OFFLINE=true cargo check -p ml --tests --features cuda  # green
  SQLX_OFFLINE=true cargo test -p ml --lib sp20                # 21/21 pass
  SQLX_OFFLINE=true cargo build -p ml                          # cubin compile

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-05-10 01:46:52 +02:00
parent 13ce783853
commit 06dbb78ffc
9 changed files with 1312 additions and 196 deletions

View File

@@ -359,6 +359,21 @@ fn main() {
// Task 2.2 inside `experience_env_step::segment_complete` branch;
// the test wrapper has no production callers.
"sp20_event_reward_test_kernel.cu",
// SP20 Phase 3 Task 3.2 (2026-05-10): standalone test wrappers
// for the two device helpers defined in `sp20_hold_baseline.cuh`
// (Component 2 — Hold opportunity-cost dual emission). Wraps
// `sp20_compute_per_bar_aux_conf_k2`,
// `sp20_sum_hold_baseline_over_trade`, and a fixture mirroring
// the production per-bar emission site so the Rust GPU oracle
// tests in `tests/sp20_hold_baseline_test.rs` drive the EXACT
// same code paths the production caller
// (`experience_env_step` per-bar branches + segment_complete
// trade-close site) executes per
// `feedback_no_cpu_test_fallbacks`. Producer call sites land in
// Task 3.2 inside `experience_env_step` (per-bar branches +
// segment_complete branch). Test wrapper has no production
// callers.
"sp20_hold_baseline_test_kernel.cu",
// SP4 Layer A Task A5 (2026-04-30): first end-to-end SP4 producer.
// Single-block kernel reads `denoise_target_q_buf`, computes
// p99(|target_q|) via `sp4_histogram_p99<256>`, writes the step

View File

@@ -97,6 +97,7 @@
* `experience_env_step`'s segment_complete branch atomically with the
* SP12 v3 reward block deletion + alpha plumbing. */
#include "sp20_reward.cuh"
#include "sp20_hold_baseline.cuh" /* Phase 3.2: Component 2 helpers */
/* ------------------------------------------------------------------ */
/* BF16 support — common_device_functions.cuh is prepended by build.rs */
@@ -2204,21 +2205,51 @@ extern "C" __global__ void experience_env_step(
float* __restrict__ alpha_per_env,
int loss_cap_idx,
int alpha_ema_idx,
/* SP20 Phase 2 Task 2.2-fix (2026-05-10) — per-env trade-close win
* flag output buffer.
/* SP20 Phase 3 Task 3.2 (2026-05-10) — Hold opportunity-cost dual
* emission (Component 2) per spec §4.2.
*
* `is_win_per_env` ← [N] i32: written at `is_close && segment_
* hold_time > 0.0f` (segment_complete branch) as
* `(segment_return > 0.0f) ? 1 : 0`. Mirrors the alpha emit site
* exactly — the SP20 aggregation kernel reads alpha sum and
* is_win count side-by-side at every closed env. Replaces the
* broken per-bar `step_ret > 0` predicate the aggregation kernel
* was using, which pinned WR_EMA at 0 in production (per-bar
* step_ret at close bars is dominated by tx-cost so almost always
* negative even for trades that closed profitably). NULL-tolerant
* for test scaffolds — when NULL the write is a no-op and the
* aggregation kernel falls back to the legacy `sr > 0` predicate. */
int* __restrict__ is_win_per_env
* `aux_logits_per_env` ← [N, K=2] f32 row-major device-resident.
* Producer: `aux_next_bar_forward` kernel (writes
* `exp_aux_nb_logits_buf`) — same producer the `sp20_stats_compute`
* kernel reads at the start of the SP20 chain. Stream-implicit
* ordering: the aux head runs at the same step's "step 3" before
* `experience_env_step` (step 5) on the same stream, so the
* logits are valid here. NULL-tolerant for test scaffolds —
* sp20_compute_per_bar_aux_conf_k2 isn't called and the dual
* emission collapses to zero (no buffer write, no centered reward)
* which preserves the existing per-bar behavior bit-identically.
*
* `hold_baseline_buffer` ← [N × hold_buffer_size] f32 row-major
* device-resident scratch. Per-env circular ring: each env writes
* its own row at `current_t % hold_buffer_size` per bar; at trade
* close, sums backwards `min(hold_buffer_size, segment_hold_time)`
* slots (consumer at the segment_complete branch). Producer:
* THIS kernel writes per-bar at the positioned-non-event +
* flat-non-event branches (always, regardless of action — Path 2
* counterfactual semantic per spec §4.2). NULL = no buffer write
* AND consumer falls back to `hold_baseline = 0.0f` (Phase 2 Task
* 2.2 placeholder behaviour, preserving backward compat for test
* scaffolds without Phase 3.2 wiring).
*
* `hold_buffer_size`: circular ring size, 30 per spec §4.2 buffer
* sizing note (matches SP19's longest horizon LOOKAHEAD_HORIZON_MAX).
*
* `aux_k`: K=2 for the binary up/not-up aux head. The
* `sp20_compute_per_bar_aux_conf_k2` helper hardcodes K=2 (matches
* the spec §4.2 K=2 AMENDED note); `aux_k` is passed in for
* future-proofing and as a defensive sanity check (currently
* asserted-equivalent inside the kernel).
*
* `hold_cost_scale_idx` / `hold_reward_ema_idx`: ISV slot indices
* for the producer / consumer reads. Caller passes them as kernel
* args so the kernel stays decoupled from SP14 slot-number drift
* (mirror the `q_dir_abs_ref_idx` pattern at line 2069). */
const float* __restrict__ aux_logits_per_env,
float* __restrict__ hold_baseline_buffer,
int hold_buffer_size,
int aux_k,
int hold_cost_scale_idx,
int hold_reward_ema_idx
) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i >= N) return;
@@ -3301,13 +3332,35 @@ extern "C" __global__ void experience_env_step(
const float r_event = sp20_compute_event_reward(
segment_return, label_at_open_sign, loss_cap
);
/* Phase 3.2 placeholder: hold_baseline = 0.0f. Component 2
* (Phase 3.2) lands the real `hold_baseline_buffer.sum_over_
* trade_range()` consumer. Forward reference per the audit-doc
* Task 2.2 entry — NOT a stub: the producer site here is fully
* wired to a working bounded scalar, the consumer just adds
* a (negative) opportunity-cost subtraction in a later phase. */
const float hold_baseline = 0.0f;
/* SP20 Phase 3 Task 3.2 (2026-05-10): hold_baseline = sum over
* the trade's bars of the per-env circular `hold_baseline_buffer`.
* Replaces the Phase 2 Task 2.2 forward-reference placeholder
* (`hold_baseline = 0.0f`) per spec §4.2.
*
* Helper walks backwards from `current_t - 1` (the most recent
* per-bar write — the trade-close bar's segment_complete branch
* does NOT write to the buffer) for `min(hold_buffer_size,
* segment_hold_time)` bars in the env's row. NULL-tolerant:
* test scaffolds without Phase 3.2 wiring fall back to 0.0
* (preserves the Phase 2 placeholder behaviour bit-identically).
*
* Bounded by construction (`pearl_one_unbounded_signal_per_reward`):
* each per-bar entry is `-aux_conf × cost_scale ∈ [-0.25, 0]`
* (aux_conf ∈ [0, 0.5], cost_scale ∈ [0.01, 0.5]); sum of
* 30 such terms is in `[-7.5, 0]`. With `r_event ∈ [-2, +1]`
* (4-quadrant table from sp20_reward.cuh), the resulting
* `alpha = r_event - hold_baseline ∈ [-2, +8.5]`. Through the
* Wiener-α `alpha_ema` EMA bound (also in `[-2, +8.5]`
* post-bootstrap), the centered `r_used = alpha - alpha_ema`
* stays bounded — no clamps needed; entirely structural. */
const float hold_baseline = (hold_baseline_buffer != NULL)
? sp20_sum_hold_baseline_over_trade(
hold_baseline_buffer,
/*env=*/i,
hold_buffer_size,
current_t,
segment_hold_time)
: 0.0f;
const float alpha = r_event - hold_baseline;
const float alpha_ema = (isv_signals_ptr != NULL)
? isv_signals_ptr[alpha_ema_idx]
@@ -3642,72 +3695,90 @@ 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;
/* SP18 D-leg Phase 2 (2026-05-09): structural Hold opportunity-cost
* counterfactual on positioned-non-event bars. Replaces the SP13
* P0a / SP16 P2 / SP16 T3 reactive per-bar Hold-cost-scale chain
* (deleted Phase 1 c66e15f6d / 3c318953a). Routes through the
* micro_reward component (rc[3]) — the same component the deleted
* subtraction wrote to via `r_micro -= cost` pre-SP18, preserving
* SP11 reward-subsystem controller signal attribution.
/* SP20 Phase 3 Task 3.2 (2026-05-10): Hold opportunity-cost dual
* emission (Component 2) per spec §4.2. Replaces the SP18 D-leg
* `compute_sp18_hold_opportunity_cost` per-bar call atomically
* with the new producer wiring (per
* `feedback_no_partial_refactor` "consumer migrates with
* contract change" — every per-bar Hold-cost site migrates in
* one commit). The SP18 `compute_sp18_hold_opportunity_cost`
* device function in `trade_physics.cuh:655` is RETAINED —
* still called by `sp18_hold_opp_test_kernel.cu` (the SP18
* oracle test surface).
*
* The device function returns 0 for `dir_idx != DIR_HOLD`, so
* unconditional addition is safe. `vol_proxy` and `next_log_return`
* are recomputed locally from `features[bar_idx * market_dim + 9]`
* + `tgt[1]` — `vol_proxy` is scoped to the segment_complete branch
* above, so we mirror the segment_complete formula exactly here.
* The `features != NULL && bar_idx < total_bars && market_dim > 9`
* guard matches the B.2/D.4c bonus blocks below; on cold-start /
* test-fixture inputs without market features we skip cleanly
* (sp18_hold_opp stays at 0.0).
* Two parallel emissions per spec §4.2:
*
* Caps come from ISV[HOLD_REWARD_NEG_CAP_INDEX=484] /
* ISV[HOLD_REWARD_POS_CAP_INDEX=483] with sentinel/window-guard
* fallback to the macro defaults — bit-identical to a fixed +5/-10
* cap during cold-start (Phase 3 lands the adaptive producer).
* `shaping_scale` matches the SP12 contract (validation = 0 → no
* Hold opp cost in pure backtest). Per
* `pearl_one_unbounded_signal_per_reward.md` the only unbounded
* multiplicand is `next_log_return / sqrt(vol_proxy)`; everything
* else is in [floor, ceil].
* per_bar_opp_cost = -aux_conf × cost_scale
*
* The structurally-bounded output (capped in [neg_cap, pos_cap])
* sits comfortably inside the SP12 asymmetric reward cap [-10, +5]
* the segment_complete branch enforces — but this branch fires
* BETWEEN trade close events (positioned-non-event), so no
* downstream cap is applied here; the device function's
* `compute_asymmetric_capped_pnl` is the structural bound. */
if (features != NULL && bar_idx < total_bars && market_dim > 9) {
const float atr_norm_pb1 =
features[(long long)bar_idx * market_dim + 9];
const float log_atr_pb1 = atr_norm_pb1 * 16.0f - 7.0f;
const float atr_pct_pb1 =
expf(log_atr_pb1) / fmaxf(raw_close, 1.0f);
const float vol_proxy_pb1 = fmaxf(atr_pct_pb1, 0.0001f);
* Path 1 — real reward (Hold-action only):
* r_micro += per_bar_opp_cost - ISV[HOLD_REWARD_EMA]
* (centered for Q-target scale comparability — Q(Hold) and
* Q(trade) live on the same relative-to-mean scale)
*
* Path 2 — counterfactual buffer (always):
* hold_baseline_buffer[env, current_t % size] = per_bar_opp_cost
* (uncentered — consumed by Component 1 trade-close site
* to compute alpha = R_event - sum_over_trade_range)
*
* NULL-tolerant: when `aux_logits_per_env == NULL` (test
* scaffolds without aux-head wiring) BOTH paths skip — the
* branch reduces to `r_micro = 0.0f` (existing behavior
* pre-Phase-3.2), preserving backward compat. When
* `hold_baseline_buffer == NULL`, only Path 2 is skipped;
* Path 1 still fires (centered Hold reward into r_micro)
* because the Hold reward is independent of the buffer.
*
* Bounded by construction (`pearl_one_unbounded_signal_per_reward`):
* aux_conf ∈ [0, 0.5] (softmax peak above K=2 uniform)
* cost_scale ∈ [0.01, 0.5] (controller bound, ISV slot 513)
* per_bar_opp_cost ∈ [-0.25, 0]
* ISV[HOLD_REWARD_EMA] ∈ [-0.25, 0] (Wiener-α EMA of bounded input)
* r_micro centered contribution ∈ [-0.25, +0.25]
*
* Pearl notes:
* - `pearl_event_driven_reward_density_alignment` — Component
* 2 IS the per-bar density alignment; the trade-close
* summation re-aligns it with the trade-event density via
* subtraction. This is intentional density-alignment, NOT
* the per-bar shaping anti-pattern (which was redundant
* pressure on the same TD signal). The Hold-Q dimension is
* SEPARATE from the trade-Q dimension; both need their own
* density alignment.
* - `pearl_one_unbounded_signal_per_reward` — softmax-bound
* and controller-bound multiplicands (no unbounded factor).
* - `pearl_first_observation_bootstrap` — `ISV[HOLD_COST_SCALE]`
* starts at 0.0 sentinel; cold-start gives per_bar_opp_cost
* = 0 until the controller's first observation lifts the
* scale. Path 2 writes 0 to the buffer (harmless — sums to
* 0 at cold-start trade-close).
*/
const int has_aux = (aux_logits_per_env != NULL);
const int has_hold_buf = (hold_baseline_buffer != NULL);
if (has_aux) {
/* aux_k=2 hardcoded per spec §4.2 AMENDED note. The
* `aux_k` arg is reserved for future K>2 support. */
const float* logits_row = aux_logits_per_env + (long long)i * (long long)aux_k;
const float aux_conf_i = sp20_compute_per_bar_aux_conf_k2(logits_row);
const float cost_scale = (isv_signals_ptr != NULL)
? isv_signals_ptr[hold_cost_scale_idx]
: 0.0f;
const float per_bar_opp_cost = -aux_conf_i * cost_scale;
float sp18_pos_cap_pb1 = SENTINEL_HOLD_REWARD_POS_CAP; /* +5.0 */
float sp18_neg_cap_pb1 = SENTINEL_HOLD_REWARD_NEG_CAP; /* -10.0 */
if (isv_signals_ptr != NULL) {
const float isv_pos =
isv_signals_ptr[HOLD_REWARD_POS_CAP_INDEX];
const float isv_neg =
isv_signals_ptr[HOLD_REWARD_NEG_CAP_INDEX];
if (fabsf(isv_pos - SENTINEL_HOLD_REWARD_POS_CAP) >= 1e-6f
&& isv_pos >= HOLD_REWARD_POS_CAP_MIN_BOUND
&& isv_pos <= HOLD_REWARD_POS_CAP_MAX_BOUND) {
sp18_pos_cap_pb1 = isv_pos;
sp18_neg_cap_pb1 = isv_neg;
}
/* Path 2 — always written when buffer wired. */
if (has_hold_buf) {
const long long row_off =
(long long)i * (long long)hold_buffer_size;
const int slot_idx = current_t % hold_buffer_size;
hold_baseline_buffer[row_off + slot_idx] = per_bar_opp_cost;
}
/* Path 1 — Hold-action only, centered. */
if (dir_idx == DIR_HOLD) {
const float hold_reward_ema = (isv_signals_ptr != NULL)
? isv_signals_ptr[hold_reward_ema_idx]
: 0.0f;
r_micro += per_bar_opp_cost - hold_reward_ema;
}
const float sp18_hold_opp_pb1 = compute_sp18_hold_opportunity_cost(
dir_idx,
/*dir_hold=*/DIR_HOLD,
/*next_log_return=*/tgt[1],
/*vol_proxy=*/vol_proxy_pb1,
/*isv_neg_cap=*/sp18_neg_cap_pb1,
/*isv_pos_cap=*/sp18_pos_cap_pb1,
/*shaping_scale=*/shaping_scale
);
r_micro += sp18_hold_opp_pb1;
}
reward = r_micro;
micro_reward_per_sample[out_off] = r_micro;
@@ -3763,62 +3834,57 @@ 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;
/* SP18 D-leg Phase 2 (2026-05-09): structural Hold opportunity-cost
* counterfactual on flat-non-event bars. Mirrors the positioned-
* branch wiring above so a policy picking Hold while flat ("stay
* flat") sees the same structural counterfactual as a policy
* picking Hold while positioned ("stay long/short"). Routes
* through the opp_cost component (rc[4]) — the same component
* the deleted SP13/SP16 chain wrote to via `r_opp_cost -= cost`
* pre-SP18, preserving SP11 reward-subsystem controller signal
* attribution.
/* SP20 Phase 3 Task 3.2 (2026-05-10): Hold opportunity-cost dual
* emission on flat-non-event bars (Component 2) — mirrors the
* positioned-branch wiring above so a policy picking Hold while
* flat ("stay flat") sees the same structural counterfactual as
* a policy picking Hold while positioned ("stay long/short").
* Routes through the opp_cost component (rc[4]).
*
* The device function returns 0 for `dir_idx != DIR_HOLD`, so
* unconditional addition is safe. `vol_proxy` and `next_log_return`
* recomputed locally — same pattern as the positioned-branch and
* the B.2/D.4c bonus blocks. Caps come from ISV[483]/[484] with
* sentinel/window-guard fallback to the macro defaults; bit-
* identical to a fixed +5/-10 cap during cold-start (Phase 3
* lands the adaptive producer). `shaping_scale=0` (validation
* mode) → device function returns 0 → no Hold opp cost in pure
* backtest, matching the SP12 lump-sum contract.
* Same dual-emission contract as the positioned branch:
*
* Output bounded by `compute_asymmetric_capped_pnl` to
* [neg_cap, pos_cap] before scaling by `shaping_scale`; sits
* inside the SP12 asymmetric reward cap range. Per
* `pearl_one_unbounded_signal_per_reward.md`. */
if (features != NULL && bar_idx < total_bars && market_dim > 9) {
const float atr_norm_pb2 =
features[(long long)bar_idx * market_dim + 9];
const float log_atr_pb2 = atr_norm_pb2 * 16.0f - 7.0f;
const float atr_pct_pb2 =
expf(log_atr_pb2) / fmaxf(raw_close, 1.0f);
const float vol_proxy_pb2 = fmaxf(atr_pct_pb2, 0.0001f);
* Path 1 — real reward into `r_opp_cost` (Hold-action only,
* centered): `r_opp_cost += per_bar_opp_cost -
* ISV[HOLD_REWARD_EMA]`.
* Path 2 — counterfactual buffer write (always — even on
* flat-non-event bars; the Hold counterfactual
* applies symmetrically across positioned/flat).
*
* Same NULL-tolerance + boundedness invariants as the
* positioned-branch documentation above. The two branches
* write to DIFFERENT reward components (rc[3] r_micro for
* positioned, rc[4] r_opp_cost for flat) per SP11 component
* attribution semantics — preserved from the SP18 D-leg
* pre-Phase-3.2 routing. */
const int has_aux_pb2 = (aux_logits_per_env != NULL);
const int has_hold_buf_pb2 = (hold_baseline_buffer != NULL);
if (has_aux_pb2) {
const float* logits_row_pb2 = aux_logits_per_env
+ (long long)i * (long long)aux_k;
const float aux_conf_pb2 =
sp20_compute_per_bar_aux_conf_k2(logits_row_pb2);
const float cost_scale_pb2 = (isv_signals_ptr != NULL)
? isv_signals_ptr[hold_cost_scale_idx]
: 0.0f;
const float per_bar_opp_cost_pb2 = -aux_conf_pb2 * cost_scale_pb2;
float sp18_pos_cap_pb2 = SENTINEL_HOLD_REWARD_POS_CAP; /* +5.0 */
float sp18_neg_cap_pb2 = SENTINEL_HOLD_REWARD_NEG_CAP; /* -10.0 */
if (isv_signals_ptr != NULL) {
const float isv_pos =
isv_signals_ptr[HOLD_REWARD_POS_CAP_INDEX];
const float isv_neg =
isv_signals_ptr[HOLD_REWARD_NEG_CAP_INDEX];
if (fabsf(isv_pos - SENTINEL_HOLD_REWARD_POS_CAP) >= 1e-6f
&& isv_pos >= HOLD_REWARD_POS_CAP_MIN_BOUND
&& isv_pos <= HOLD_REWARD_POS_CAP_MAX_BOUND) {
sp18_pos_cap_pb2 = isv_pos;
sp18_neg_cap_pb2 = isv_neg;
}
/* Path 2 — always written when buffer wired. */
if (has_hold_buf_pb2) {
const long long row_off_pb2 =
(long long)i * (long long)hold_buffer_size;
const int slot_idx_pb2 = current_t % hold_buffer_size;
hold_baseline_buffer[row_off_pb2 + slot_idx_pb2] =
per_bar_opp_cost_pb2;
}
/* Path 1 — Hold-action only, centered. Routes through
* r_opp_cost (rc[4]) per the flat-branch SP11 attribution. */
if (dir_idx == DIR_HOLD) {
const float hold_reward_ema_pb2 = (isv_signals_ptr != NULL)
? isv_signals_ptr[hold_reward_ema_idx]
: 0.0f;
r_opp_cost += per_bar_opp_cost_pb2 - hold_reward_ema_pb2;
}
const float sp18_hold_opp_pb2 = compute_sp18_hold_opportunity_cost(
dir_idx,
/*dir_hold=*/DIR_HOLD,
/*next_log_return=*/tgt[1],
/*vol_proxy=*/vol_proxy_pb2,
/*isv_neg_cap=*/sp18_neg_cap_pb2,
/*isv_pos_cap=*/sp18_pos_cap_pb2,
/*shaping_scale=*/shaping_scale
);
r_opp_cost += sp18_hold_opp_pb2;
}
reward = r_opp_cost;
reward_components_per_sample[out_off * 6 + 4] = r_opp_cost;

View File

@@ -51,6 +51,15 @@ const PORTFOLIO_STRIDE: usize = 43; // was 41. Slots 30-37: prev-bar OFI; slot
/// Layout: [win_count, loss_count, sum_wins, sum_losses, sum_returns, sum_sq_returns]
const TRADE_STATS_FLOATS: usize = 6;
/// SP20 Phase 3 Task 3.2 (2026-05-10): circular per-env Hold
/// opportunity-cost buffer size. Per spec §4.2: "circular buffer of
/// size LOOKAHEAD_HORIZON_MAX = 30 bars (matches SP19's longest
/// horizon and bounds max trade duration)". Bars older than 30 are
/// overwritten (acceptable per spec — trades > 30 bars don't sample
/// beyond their last 30 bars). Used in `experience_env_step` Hold
/// opportunity-cost dual-emission (Component 2).
pub const HOLD_BASELINE_BUFFER_SIZE: usize = 30;
// ---------------------------------------------------------------------------
// TradeStats — GPU-sourced per-epoch trade statistics
// ---------------------------------------------------------------------------
@@ -706,34 +715,56 @@ pub struct GpuExperienceCollector {
/// don't leak into the EMA.
pub(crate) alpha_per_env: CudaSlice<f32>, // [alloc_episodes]
/// SP20 Phase 2 Task 2.2-fix (2026-05-10): per-env trade-close win
/// flag. `[alloc_episodes]` i32 device-resident scratch — `1` iff
/// the segment's realized P&L was strictly positive at trade close
/// (`segment_return > 0.0f`), `0` otherwise. Replaces the broken
/// per-bar `step_ret > 0` predicate the aggregation kernel was
/// using, which conflated the close bar's per-bar mark-to-market
/// tick (dominated by tx-cost so almost always false at close
/// bars) with trade outcome — pinning WR_EMA at 0 across all
/// training epochs in production.
/// SP20 Phase 3 Task 3.2 (2026-05-10): per-env circular Hold
/// opportunity-cost buffer (Component 2) per spec §4.2.
///
/// Layout: `[alloc_episodes]` i32 — one slot per env, written by
/// `experience_env_step`'s `is_close` (`segment_complete`) branch
/// at the same site as `alpha_per_env`. Read once per rollout
/// step by `sp20_aggregate_inputs_kernel` to populate the
/// `wins_count` reduction (mirror of the alpha-mean aggregation).
/// Layout: `[alloc_episodes × HOLD_BASELINE_BUFFER_SIZE = 30]` f32
/// row-major device-resident scratch.
///
/// Producer: `experience_env_step` per-bar branches
/// (positioned-non-event line ~3650 + flat-non-event line ~3737)
/// — both write `hold_baseline_buffer[env, current_t %
/// HOLD_BASELINE_BUFFER_SIZE] = -aux_conf × cost_scale` UNCONDITIONALLY
/// (Path 2 counterfactual emission, regardless of action). The
/// segment_complete branch does NOT write — the trade-close bar's
/// reward path bypasses the per-bar branches.
///
/// Consumer: `experience_env_step` `is_close && segment_hold_time
/// > 0.0f` branch — sums backwards `min(HOLD_BASELINE_BUFFER_SIZE,
/// segment_hold_time)` slots from `(current_t - 1) %
/// HOLD_BASELINE_BUFFER_SIZE` to compute `hold_baseline`, replacing
/// the Phase 2 Task 2.2 placeholder `hold_baseline = 0.0f`. The
/// resulting `alpha = R_event - hold_baseline` flows through to
/// `alpha_per_env[env]` for the SP20 aggregation kernel
/// (per_bar EMA on Hold-state bars only — the
/// `sp20_aggregate_inputs_kernel` Phase 3.2 forward-reference for
/// `per_bar_hold_reward` lands separately on the
/// `sp20-phase-2-fix` branch per concurrent-agent collision
/// avoidance).
///
/// Buffer-sizing rationale (spec §4.2): 30 bars matches SP19's
/// longest horizon (`LOOKAHEAD_HORIZON_MAX`). Bars older than 30
/// are overwritten — fine because trades > 30 bars don't sample
/// beyond their last 30 bars per the spec's bounded-trade-duration
/// argument.
///
/// FoldReset semantics (registered in `state_reset_registry.rs` as
/// `is_win_per_env`): zero on every fold boundary so the new
/// fold's first trade-close fully populates the slot from the new
/// fold's segment_return calculation (no stale win flag leakage
/// across folds — each fold has independent WR_EMA bootstrap state
/// per Phase 1.4's `obs_count[OBS_COUNT_WR]` reset).
/// `hold_baseline_buffer`): zero on every fold boundary so the new
/// fold's first trade-close summation reads only this fold's
/// per-bar writes — no stale bars leak across folds. Reset path:
/// `cudarc::driver::CudaStream::memset_zeros` on the `CudaSlice<f32>`
/// (device-resident, no host buffer to fill).
///
/// On non-close steps the slot retains the previous trade-close
/// flag (or 0 sentinel pre-first-close); the aggregation kernel
/// reads only when `trade_close_per_env[env] != 0`, so stale
/// values don't leak into the EMA.
pub(crate) is_win_per_env: CudaSlice<i32>, // [alloc_episodes]
/// Per `pearl_first_observation_bootstrap`: cold-start with
/// `ISV[HOLD_COST_SCALE]` at sentinel 0 means
/// `per_bar_opp_cost = 0` (Path 2 writes zeros to the buffer);
/// the trade-close consumer sums zeros and lands `hold_baseline =
/// 0.0f` (matches Phase 2 Task 2.2 placeholder behaviour bit-for-
/// bit on the very first trade close after fold reset). The
/// controller's first observation lifts cost_scale, then per_bar
/// writes become non-zero, then the next trade-close summation
/// engages — gracefully bootstrapped, no warmup gate needed.
pub(crate) hold_baseline_buffer: CudaSlice<f32>, // [alloc_episodes × 30]
// Output buffers [alloc_episodes * alloc_timesteps, ...]
states_out: CudaSlice<f32>, // #30 [alloc_episodes * alloc_timesteps * STATE_DIM] f32
@@ -1805,19 +1836,18 @@ impl GpuExperienceCollector {
"sp20: alloc alpha_per_env ({alloc_episodes} f32): {e}"
)))?;
// SP20 Phase 2 Task 2.2-fix (2026-05-10): per-env trade-close
// win flag (1 iff segment_return > 0 at trade close, else 0).
// Sentinel = 0; updated at each trade close by the same
// SP20 4-quadrant reward kernel block site that writes
// `alpha_per_env`. Consumed by sp20_aggregate_inputs_kernel
// (count over closed envs with flag=1) for the wins_count
// reduction. Replaces the broken per-bar `step_ret > 0`
// predicate that pinned WR_EMA at 0 in production.
// Sized [alloc_episodes].
let is_win_per_env = stream
.alloc_zeros::<i32>(alloc_episodes)
// SP20 Phase 3 Task 3.2 (2026-05-10): per-env Hold opportunity-
// cost circular buffer. Layout `[alloc_episodes ×
// HOLD_BASELINE_BUFFER_SIZE = 30]` f32 row-major. Sentinel 0.0
// — at cold-start (before the controller lifts ISV[513] off
// sentinel) per-bar writes are 0, so cold-start trade closes
// sum to 0.0 (bit-identical to the Phase 2 Task 2.2 placeholder
// semantic per `pearl_first_observation_bootstrap`).
let hold_baseline_buffer = stream
.alloc_zeros::<f32>(alloc_episodes * HOLD_BASELINE_BUFFER_SIZE)
.map_err(|e| MLError::ModelError(format!(
"sp20: alloc is_win_per_env ({alloc_episodes} i32): {e}"
"sp20: alloc hold_baseline_buffer ({} f32): {e}",
alloc_episodes * HOLD_BASELINE_BUFFER_SIZE
)))?;
// Persistent epoch state
@@ -2849,7 +2879,7 @@ impl GpuExperienceCollector {
episode_starts_buf,
label_at_open_per_env,
alpha_per_env,
is_win_per_env,
hold_baseline_buffer,
states_out,
actions_out,
rewards_out,
@@ -6267,16 +6297,40 @@ impl GpuExperienceCollector {
use crate::cuda_pipeline::sp14_isv_slots::ALPHA_EMA_INDEX;
ALPHA_EMA_INDEX as i32
})
// SP20 Phase 2 Task 2.2-fix (2026-05-10): per-env
// trade-close win flag (output) — 1 iff
// `segment_return > 0` at trade close. Written at
// the same segment_complete branch site as
// `alpha_per_env`. Consumed by
// `sp20_aggregate_inputs_kernel` for the
// `wins_count` reduction (replaces the broken
// per-bar `step_ret > 0` predicate that pinned
// WR_EMA at 0 in production).
.arg(&mut self.is_win_per_env)
// SP20 Phase 3 Task 3.2 (2026-05-10): Hold
// opportunity-cost dual emission (Component 2). Six
// new args at the end of the kernel signature:
// - aux_logits_per_env [N, K=2] f32 input —
// reuses `exp_aux_nb_logits_buf` (the same
// buffer the SP20 stats kernel reads at
// line 6435 above). Stream-implicit
// producer→consumer: aux head writes at step 3
// (line ~5328), env_step reads here at step 5.
// - hold_baseline_buffer [N × 30] f32
// scratch — per-env circular ring written at
// per-bar branches, summed at trade close.
// - hold_buffer_size = 30 (HOLD_BASELINE_BUFFER_SIZE).
// - aux_k = 2 (AUX_NEXT_BAR_K — defensive
// companion to the K=2 hardcoded helper).
// - hold_cost_scale_idx, hold_reward_ema_idx —
// ISV slot indices (mirror the loss_cap_idx /
// alpha_ema_idx pattern above; single source
// of truth at sp14_isv_slots.rs constants).
.arg(&self.exp_aux_nb_logits_buf.raw_ptr())
.arg(&mut self.hold_baseline_buffer)
.arg(&{ HOLD_BASELINE_BUFFER_SIZE as i32 })
.arg(&{
use super::gpu_aux_heads::AUX_NEXT_BAR_K;
AUX_NEXT_BAR_K as i32
})
.arg(&{
use crate::cuda_pipeline::sp14_isv_slots::HOLD_COST_SCALE_INDEX;
HOLD_COST_SCALE_INDEX as i32
})
.arg(&{
use crate::cuda_pipeline::sp14_isv_slots::HOLD_REWARD_EMA_INDEX;
HOLD_REWARD_EMA_INDEX as i32
})
.launch(launch_cfg)
.map_err(|e| MLError::ModelError(format!(
"experience_env_step t={t}: {e}"

View File

@@ -0,0 +1,168 @@
/* ══════════════════════════════════════════════════════════════════════════
* SP20 Phase 3 Task 3.2 (2026-05-10) — Hold opportunity-cost dual emission
* helpers (Component 2).
*
* Per spec `docs/superpowers/specs/2026-05-09-sp19-20-wr-first-design.md` §4.2.
*
* Two helpers shared between `experience_env_step` (production caller) and
* the SP20 hold-baseline GPU oracle test wrapper:
*
* 1. `sp20_compute_per_bar_aux_conf_k2(logits)`:
* Per-row K=2 peak-confidence above the K=2 uniform mean.
* `aux_conf = max(softmax(logits)) - 0.5` ∈ [0, 0.5].
* Numerically-stable: subtract row-max before exp.
* Bit-identical to the formula in `sp20_stats_compute_kernel.cu`
* Pass A (lines 152-167) — the per-row aux_conf computation that
* feeds the batch p50/std reduction.
*
* 2. `sp20_sum_hold_baseline_over_trade(buf, env, hold_buffer_size,
* current_t, segment_hold_time)`:
* Sum the per-env circular `hold_baseline_buffer` over the most
* recent `min(hold_buffer_size, segment_hold_time)` bars BEFORE
* `current_t`. The trade-close bar's reward path doesn't write
* to the buffer (segment_complete bypasses the per-bar branches),
* so the most recent write was at `(current_t - 1) %
* hold_buffer_size`, walking backwards.
*
* Layout: `buf[env * hold_buffer_size + (current_t - k) %
* hold_buffer_size]` for k = 1..=n_bars where
* `n_bars = min(hold_buffer_size, segment_hold_time)`.
*
* `segment_hold_time` is captured BEFORE the trade-physics helper
* resets `PS_HOLD_TIME` to 0 (saved_hold_time at
* experience_kernels.cu:2618), so it correctly reflects the trade's
* duration.
*
* Trades older than `hold_buffer_size` get UNDER-counted (only the
* most recent 30 bars are summed). Per spec §4.2 buffer-sizing
* note this is acceptable: "trades > 30 bars don't sample beyond
* their last 30 bars."
*
* ── Pearls + invariants ────────────────────────────────────────────────
* - `pearl_event_driven_reward_density_alignment` — Component 2's
* per-bar density alignment is intentional: every bar contributes a
* counterfactual cost ("what would Hold have paid"), and the trade
* consumer sums the trade's bars to subtract from the trade-close
* R_event. Density-aligned by construction.
* - `pearl_one_unbounded_signal_per_reward` — `aux_conf ∈ [0, 0.5]`
* (softmax-bounded), `cost_scale ∈ [0.01, 0.5]` (controller-bounded),
* so `per_bar_opp_cost = -aux_conf × cost_scale ∈ [-0.25, 0]`. Sum of
* 30 such terms is in `[-7.5, 0]`. Bounded by construction.
* - `feedback_no_atomicadd` — both helpers are pure read/compute (no
* writes to shared memory or device memory).
* - `feedback_no_cpu_test_fallbacks` — both helpers are __device__ +
* __forceinline__ so the test wrapper can call them bit-for-bit.
* - `feedback_isv_for_adaptive_bounds` — `cost_scale` is read from
* `ISV[HOLD_COST_SCALE_INDEX]` by the caller; this header takes it
* as a scalar arg, decoupled from slot indices.
*
* ── Wiring contract ────────────────────────────────────────────────────
*
* Per-bar emission (positioned-non-event AND flat-non-event branches):
* - Read `aux_conf_i = sp20_compute_per_bar_aux_conf_k2(...)` once.
* - Read `cost_scale = ISV[HOLD_COST_SCALE_INDEX]`.
* - Compute `per_bar_opp_cost = -aux_conf × cost_scale`.
* - Write Path 2 (counterfactual): `hold_baseline_buffer[env *
* hold_buffer_size + current_t % hold_buffer_size] = per_bar_opp_cost`
* (always, regardless of action).
* - Write Path 1 (real reward): if `dir_idx == DIR_HOLD`,
* `r_micro_or_opp_cost += per_bar_opp_cost - ISV[HOLD_REWARD_EMA_INDEX]`
* (centered for Q-target stability).
*
* Trade-close consumer (segment_complete branch):
* - `hold_baseline = sp20_sum_hold_baseline_over_trade(...)` —
* replaces the Phase 2 Task 2.2 placeholder `hold_baseline = 0.0f`.
* - `alpha = R_event - hold_baseline` continues to feed
* `alpha_per_env[env]` for the SP20 aggregation kernel.
*
* ══════════════════════════════════════════════════════════════════════════ */
#ifndef SP20_HOLD_BASELINE_CUH
#define SP20_HOLD_BASELINE_CUH
#include <cuda_runtime.h>
/* K=2 uniform softmax mean. Subtracted from peak softmax to produce
* `aux_conf ∈ [0, 0.5]`. Exactly representable in f32. */
#define SP20_HOLD_BASELINE_UNIFORM_K2 0.5f
/* Per-row K=2 peak-confidence: `max(softmax(logits)) - 0.5`. Numerically
* stable via row-max subtraction. Returns f32 in [0, 0.5]. */
__device__ __forceinline__ float sp20_compute_per_bar_aux_conf_k2(
const float* __restrict__ logits_row /* [K=2] f32 */
) {
const float l0 = logits_row[0];
const float l1 = logits_row[1];
const float m = fmaxf(l0, l1);
const float e0 = __expf(l0 - m);
const float e1 = __expf(l1 - m);
const float denom = e0 + e1;
/* denom >= 1.0f always (the larger of e0/e1 is exp(0) = 1.0
* after row-max subtract), so no division-by-zero guard needed. */
const float sm0 = e0 / denom;
const float sm1 = e1 / denom;
const float max_sm = fmaxf(sm0, sm1);
return max_sm - SP20_HOLD_BASELINE_UNIFORM_K2;
}
/* Sum over a per-env trade range of the circular `hold_baseline_buffer`.
*
* Args:
* buf — [N_envs × hold_buffer_size] f32 row-major.
* env — env index (row).
* hold_buffer_size — circular ring size (spec §4.2: 30).
* current_t — current bar (the trade-close bar — its OWN slot
* was NOT written by any per-bar branch because
* segment_complete bypasses them).
* segment_hold_time — float (saved_hold_time captured before
* PS_HOLD_TIME reset). The trade's bar count.
*
* Returns:
* Sum of `buf[env, (current_t - k) % hold_buffer_size]` for
* k = 1..=min(hold_buffer_size, floor(segment_hold_time)).
* Walks backwards from `current_t - 1` (the most recent per-bar write).
*
* Pearl note: kernel is per-env-parallel. Each thread owns exactly one
* env's row; no atomic / cross-thread reads. The modulo arithmetic is
* scalar so the loop is sequential per thread (cheap — at most 30
* iterations). */
__device__ __forceinline__ float sp20_sum_hold_baseline_over_trade(
const float* __restrict__ buf,
int env,
int hold_buffer_size,
int current_t,
float segment_hold_time
) {
/* Defensive guard: empty trade or zero buffer ⇒ no contribution. */
if (hold_buffer_size <= 0) return 0.0f;
if (segment_hold_time <= 0.0f) return 0.0f;
/* Bound the back-walk. Cast through int for the loop counter; the
* kernel-side caller passes `(int)segment_hold_time` semantics
* already (saved_hold_time is `(float)hold_time` at the trade-close
* branch — non-negative integer values stored as f32). */
int n_bars = (int)segment_hold_time;
if (n_bars > hold_buffer_size) n_bars = hold_buffer_size;
/* Modulo arithmetic for negative numerators is implementation-
* defined in C / undefined for very negative values; bring the
* starting index into the positive range first. The expression
* ((current_t - k) % hold_buffer_size + hold_buffer_size)
* % hold_buffer_size
* computes the canonical non-negative modulo result. We pre-add
* hold_buffer_size once before the loop so each iteration just
* decrements. */
const long long row_off = (long long)env * (long long)hold_buffer_size;
int idx = (current_t - 1) % hold_buffer_size;
if (idx < 0) idx += hold_buffer_size;
float sum = 0.0f;
for (int k = 0; k < n_bars; ++k) {
sum += buf[row_off + idx];
--idx;
if (idx < 0) idx += hold_buffer_size;
}
return sum;
}
#endif /* SP20_HOLD_BASELINE_CUH */

View File

@@ -0,0 +1,134 @@
/* ══════════════════════════════════════════════════════════════════════════
* SP20 Phase 3 Task 3.2 (2026-05-10) — sp20_hold_baseline GPU oracle test wrappers.
*
* Standalone test kernels exercising the two device helpers in
* `sp20_hold_baseline.cuh` so the Rust GPU oracle tests in
* `tests/sp20_hold_baseline_test.rs` can drive synthetic inputs through
* the EXACT same code paths the production caller
* (`experience_env_step` per-bar branches + segment_complete trade-close
* site) executes — no CPU reference impl per
* `feedback_no_cpu_test_fallbacks`.
*
* Test-only: no production callers. Cubins built by
* `crates/ml/build.rs` (kernel names listed below in the order they
* appear in this file).
*
* Two wrappers shipped:
*
* 1. `sp20_per_bar_aux_conf_k2_test_kernel(logits[2], out)`:
* Calls `sp20_compute_per_bar_aux_conf_k2` once. Single-thread
* launch — the helper is pure register-only arithmetic.
*
* 2. `sp20_sum_hold_baseline_over_trade_test_kernel(buf, env,
* hold_buffer_size,
* current_t,
* segment_hold_time,
* out)`:
* Calls `sp20_sum_hold_baseline_over_trade` once. Single-thread
* launch.
*
* 3. `sp20_dual_emission_test_kernel(...)`:
* Mini-fixture that mirrors the production per-bar emission
* site (positioned-non-event branch line ~3650 of
* experience_kernels.cu). Synthetic single-env inputs:
* per_bar_opp_cost = -aux_conf × cost_scale; if action == Hold,
* emit centered = per_bar_opp_cost - hold_reward_ema; always
* write per_bar_opp_cost to buf[0, current_t % size]. Output
* captures BOTH centered + the buffer slot for assertion. This
* wrapper validates the full dual-emission contract on a tight
* synthetic fixture.
*
* Outputs go to mapped-pinned buffers with `__threadfence_system()`
* before thread exit so the host can read via `read_volatile` after a
* stream sync. Same pattern as `sp20_event_reward_test_kernel.cu`.
* ══════════════════════════════════════════════════════════════════════════ */
#include <cuda_runtime.h>
#include "sp20_hold_baseline.cuh"
/* ── Test 1: sp20_compute_per_bar_aux_conf_k2 ─────────────────────────── */
extern "C" __global__ void sp20_per_bar_aux_conf_k2_test_kernel(
const float* __restrict__ logits, /* [K=2] */
float* __restrict__ out_aux_conf)
{
if (threadIdx.x != 0 || blockIdx.x != 0) return;
out_aux_conf[0] = sp20_compute_per_bar_aux_conf_k2(logits);
__threadfence_system();
}
/* ── Test 2: sp20_sum_hold_baseline_over_trade ────────────────────────── */
extern "C" __global__ void sp20_sum_hold_baseline_over_trade_test_kernel(
const float* __restrict__ buf,
int env,
int hold_buffer_size,
int current_t,
float segment_hold_time,
float* __restrict__ out_sum)
{
if (threadIdx.x != 0 || blockIdx.x != 0) return;
out_sum[0] = sp20_sum_hold_baseline_over_trade(
buf, env, hold_buffer_size, current_t, segment_hold_time
);
__threadfence_system();
}
/* ── Test 3: full dual-emission shape ─────────────────────────────────────
*
* Mirrors the production per-bar emission site (positioned-non-event
* branch ~line 3650 of experience_kernels.cu) on a single env. Inputs:
* - logits[2] — aux head logits (drives aux_conf).
* - cost_scale — ISV[HOLD_COST_SCALE_INDEX] (synthetic).
* - hold_reward_ema — ISV[HOLD_REWARD_EMA_INDEX] (synthetic).
* - dir_idx — action direction (DIR_HOLD = 1 fires Path 1).
* - hold_action_id — DIR_HOLD constant (= 1 in production).
* - current_t — bar index for buffer slot.
* - hold_buffer_size — circular ring size (= 30 in production).
*
* Outputs:
* - out_centered[0] — Path 1 result: 0 if not Hold;
* (per_bar_opp_cost - hold_reward_ema) if Hold.
* - out_buf[0] — Path 2 result: per_bar_opp_cost
* (regardless of action) at slot
* [0 × hold_buffer_size + current_t % size].
*
* Asserts the spec §4.2 dual-emission contract:
* per_bar_opp_cost = -aux_conf × cost_scale
* Path 1 (Hold-only): centered = per_bar_opp_cost - hold_reward_ema
* Path 2 (always): buf[..] = per_bar_opp_cost (UNcentered)
*
* The `out_buf` arg is a [hold_buffer_size] device-resident array
* (env_count = 1 in this fixture). The kernel writes the slot for
* env=0 at `current_t % hold_buffer_size`.
*/
extern "C" __global__ void sp20_dual_emission_test_kernel(
const float* __restrict__ logits, /* [K=2] */
float cost_scale,
float hold_reward_ema,
int dir_idx,
int hold_action_id,
int current_t,
int hold_buffer_size,
float* __restrict__ out_centered, /* [1] */
float* __restrict__ out_buf) /* [hold_buffer_size] */
{
if (threadIdx.x != 0 || blockIdx.x != 0) return;
const float aux_conf = sp20_compute_per_bar_aux_conf_k2(logits);
const float per_bar_opp_cost = -aux_conf * cost_scale;
/* Path 2 — always */
const int slot_idx = current_t % hold_buffer_size;
out_buf[slot_idx] = per_bar_opp_cost;
/* Path 1 — Hold-only, centered */
if (dir_idx == hold_action_id) {
out_centered[0] = per_bar_opp_cost - hold_reward_ema;
} else {
out_centered[0] = 0.0f;
}
__threadfence_system();
}

View File

@@ -2132,16 +2132,17 @@ impl StateResetRegistry {
category: ResetCategory::FoldReset,
description: "GpuExperienceCollector.alpha_per_env [alloc_episodes] f32 device-resident scratch — SP20 Phase 2 Task 2.2 (2026-05-09) per-env trade-close alpha (= R_event - hold_baseline) per spec §4.1. Phase 2 emits `hold_baseline = 0.0f` as a Phase 3.2 forward-reference placeholder; Phase 3.2's Hold opportunity-cost dual emission lands the real `hold_baseline_buffer.sum_over_trade_range()` consumer. Producer: `experience_env_step` `is_close` (segment_complete) branch — writes `alpha = sp20_compute_event_reward(segment_return, label_at_open_per_env[env], ISV[LOSS_CAP_INDEX])`. Consumer: `sp20_aggregate_inputs_kernel` (Path C aggregation) — sums `alpha_per_env[env]` over closed envs and divides by closed-count to populate `EmaInputs.alpha`. FoldReset sentinel 0.0 across all `alloc_episodes` slots — leftover alpha from the previous fold's last trade-close would corrupt the new fold's first ALPHA_EMA observation. The sentinel-0 reads as `alpha == 0` for non-close steps; the aggregation kernel reads only when `trade_close_per_env[env] != 0` so the FoldReset sentinel never reaches the EMA. Reset path: `cudarc::driver::CudaStream::memset_zeros` on the `CudaSlice<f32>` (device-resident, no host buffer to fill). Replaces the Phase 1.4 hardcoded `out_inputs->alpha = 0.0f` placeholder atomically per `feedback_no_partial_refactor` (Task 2.2 lands buffer + kernel-arg + aggregation-kernel-update + 2 placeholder pin tests in one commit).",
},
// ── SP20 Phase 2 Task 2.2-fix (2026-05-10): per-env
// trade-close win flag scratch — written at `is_close`
// (segment_complete) alongside `alpha_per_env`, read by
// `sp20_aggregate_inputs_kernel` for the `wins_count`
// reduction (replacing the broken per-bar `step_ret > 0`
// predicate that pinned WR_EMA at 0 in production).
// ── SP20 Phase 3 Task 3.2 (2026-05-10): per-env Hold
// opportunity-cost circular buffer. Component 2 producer/
// consumer per spec §4.2. Written at every per-bar
// branch (positioned-non-event + flat-non-event); summed
// at trade-close to compute hold_baseline; replaces the
// Phase 2 Task 2.2 forward-reference placeholder
// `hold_baseline = 0.0f` atomically.
RegistryEntry {
name: "is_win_per_env",
name: "hold_baseline_buffer",
category: ResetCategory::FoldReset,
description: "GpuExperienceCollector.is_win_per_env [alloc_episodes] i32 device-resident scratch — SP20 Phase 2 Task 2.2-fix (2026-05-10) per-env trade-close win flag (1 iff segment_return > 0 at trade close, 0 otherwise). Producer: `experience_env_step` `is_close` (segment_complete) branch — writes `is_win_per_env[env] = (segment_return > 0.0f) ? 1 : 0` at the same site as `alpha_per_env[env] = alpha`. Consumer: `sp20_aggregate_inputs_kernel` (Path C aggregation) — counts `is_win_per_env[env] != 0` over closed envs to populate the `wins_count` reduction (drives `EmaInputs.is_win` via the >=0.5 fraction-of-closed threshold). Replaces the broken `step_ret_per_env[env] > 0.0f` per-bar predicate the kernel was using, which conflated the close bar's per-bar mark-to-market tick (dominated by tx-cost so almost always negative at close bars) with trade outcome — pinning WR_EMA at 0 across all training epochs in production despite real wins. FoldReset sentinel 0 across all `alloc_episodes` slots — leftover win flag from the previous fold's last trade-close would corrupt the new fold's first WR_EMA observation. The sentinel-0 reads as `is_win == 0` for non-close steps; the aggregation kernel reads only when `trade_close_per_env[env] != 0` so the FoldReset sentinel never reaches the EMA. Reset path: `cudarc::driver::CudaStream::memset_zeros` on the `CudaSlice<i32>` (device-resident, no host buffer to fill). Lands atomically per `feedback_no_partial_refactor` with the kernel-body change + buffer alloc + producer wire-up in `experience_kernels.cu::segment_complete` + the new GPU oracle test `wr_ema_uses_segment_pnl_via_is_win_per_env_predicate` (failing-test commit 1, fix commit 2).",
description: "GpuExperienceCollector.hold_baseline_buffer [alloc_episodes × HOLD_BASELINE_BUFFER_SIZE = 30] f32 device-resident scratch — SP20 Phase 3 Task 3.2 (2026-05-10) per-env circular Hold opportunity-cost buffer per spec §4.2 (Component 2). Layout: row-major, `[env, current_t % 30]` slot per per-bar write. Producer: `experience_env_step` per-bar branches (positioned-non-event ~line 3650 + flat-non-event ~line 3737) — both write `hold_baseline_buffer[env, current_t % 30] = -aux_conf × cost_scale` UNCONDITIONALLY (Path 2 counterfactual emission, regardless of action; spec §4.2 line 225 `hold_baseline_buffer[i] = R_per_bar_i // uncentered, for Component 1`). Consumer: `experience_env_step` `is_close && segment_hold_time > 0.0f` branch — `hold_baseline = sp20_sum_hold_baseline_over_trade(buf, env, 30, current_t, segment_hold_time)` walking backwards `min(30, segment_hold_time)` slots from `(current_t - 1) % 30`. Replaces the Phase 2 Task 2.2 placeholder `hold_baseline = 0.0f` atomically per `feedback_no_partial_refactor`. FoldReset sentinel 0.0 across all `alloc_episodes × 30` slots — leftover per-bar costs from the previous fold's open trade would corrupt the new fold's first trade-close summation (the new fold's portfolio_states is also FoldReset so any trade open at fold boundary is impossible, but if a buffer slot retained a stale value it could leak into the first trade-close after fold reset via the modulo-arithmetic indexing). Reset path: `cudarc::driver::CudaStream::memset_zeros` on the `CudaSlice<f32>` (device-resident, no host buffer to fill). Per `pearl_first_observation_bootstrap`: cold-start with `ISV[HOLD_COST_SCALE_INDEX=513]` at sentinel 0 means `per_bar_opp_cost = 0` (Path 2 writes zeros to the buffer); the trade-close consumer sums zeros and lands `hold_baseline = 0.0f` (matches Phase 2 Task 2.2 placeholder behaviour bit-for-bit on the very first trade close after fold reset). The controller's first observation lifts cost_scale, then per-bar writes become non-zero, then the next trade-close summation engages — gracefully bootstrapped, no warmup gate needed.",
},
];
Self { entries }
@@ -2516,4 +2517,47 @@ mod sp20_registry_tests {
entry.description
);
}
/// SP20 Phase 3 Task 3.2: `hold_baseline_buffer` MUST be registered
/// as FoldReset so the new fold's first trade-close summation reads
/// only this fold's per-bar writes — no stale bars leak across
/// folds. Description MUST mention SP20 Phase 3 + Task 3.2 +
/// per-bar branches (producer) + segment_complete (consumer) so
/// wire-up audits have a stable search anchor.
#[test]
fn sp20_hold_baseline_buffer_registered_fold_reset() {
let registry = StateResetRegistry::new();
let entry = registry
.entries
.iter()
.find(|e| e.name == "hold_baseline_buffer")
.expect("hold_baseline_buffer must be registered in StateResetRegistry");
assert_eq!(
entry.category,
ResetCategory::FoldReset,
"hold_baseline_buffer must be FoldReset (got {:?})",
entry.category
);
assert!(
entry.description.contains("SP20 Phase 3"),
"description must reference 'SP20 Phase 3'; got: {}",
entry.description
);
assert!(
entry.description.contains("Task 3.2"),
"description must reference 'Task 3.2'; got: {}",
entry.description
);
assert!(
entry.description.contains("per-bar branches"),
"description must reference 'per-bar branches' producer site; got: {}",
entry.description
);
assert!(
entry.description.contains("segment_complete")
|| entry.description.contains("trade-close"),
"description must reference the segment_complete / trade-close consumer; got: {}",
entry.description
);
}
}

View File

@@ -9566,15 +9566,18 @@ impl DQNTrainer {
)))?;
}
}
// SP20 Phase 2 Task 2.2-fix (2026-05-10): per-env trade-
// close win flag scratch. Same device-resident
// `CudaSlice<i32>` reset pattern as `alpha_per_env` above.
"is_win_per_env" => {
// SP20 Phase 3 Task 3.2 (2026-05-10): per-env Hold
// opportunity-cost circular buffer (Component 2). Same
// device-resident `CudaSlice<f32>` reset pattern as
// `alpha_per_env` above. Layout `[alloc_episodes ×
// HOLD_BASELINE_BUFFER_SIZE = 30]` f32 row-major; reset
// path zeros all alloc_episodes × 30 slots.
"hold_baseline_buffer" => {
if let Some(ref mut collector) = self.gpu_experience_collector {
let stream = collector.stream().clone();
stream.memset_zeros(&mut collector.is_win_per_env)
stream.memset_zeros(&mut collector.hold_baseline_buffer)
.map_err(|e| crate::MLError::ModelError(format!(
"is_win_per_env memset_zeros: {e}"
"hold_baseline_buffer memset_zeros: {e}"
)))?;
}
}

View File

@@ -0,0 +1,412 @@
//! SP20 Phase 3 Task 3.2 (2026-05-10) — `sp20_hold_baseline.cuh` GPU oracle tests.
//!
//! Verifies the two device helpers in `sp20_hold_baseline.cuh`
//! (Component 2 / Hold opportunity-cost dual emission per spec §4.2):
//!
//! - `sp20_compute_per_bar_aux_conf_k2(logits)`:
//! Per-row K=2 peak-confidence above the K=2 uniform mean.
//! `aux_conf = max(softmax(logits)) - 0.5` ∈ [0, 0.5].
//!
//! - `sp20_sum_hold_baseline_over_trade(buf, env, size, current_t,
//! segment_hold_time)`:
//! Sum the per-env circular buffer over the most recent
//! `min(size, segment_hold_time)` slots BEFORE `current_t`.
//!
//! - The full dual-emission shape (mirrors the production per-bar
//! emission site at `experience_env_step` per-bar branches): Path 1
//! centered Hold reward AND Path 2 uncentered buffer write, with
//! the Hold-only / always action gating contract.
//!
//! Tests assert invariants per
//! `pearl_tests_must_prove_not_lock_observations` (boundedness,
//! formula correctness, fixed points), not observed-from-a-regression-
//! run values.
//!
//! Run on a GPU host:
//!
//! SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 \
//! cargo test -p ml --test sp20_hold_baseline_test --features cuda \
//! -- --ignored --nocapture
#![allow(clippy::tests_outside_test_module)]
#[cfg(feature = "cuda")]
#[allow(unsafe_code)]
mod gpu {
use std::sync::Arc;
use cudarc::driver::{CudaContext, CudaFunction, CudaStream, PushKernelArg};
use ml::cuda_pipeline::mapped_pinned::MappedF32Buffer;
const TEST_CUBIN: &[u8] = include_bytes!(concat!(
env!("OUT_DIR"),
"/sp20_hold_baseline_test_kernel.cubin"
));
fn make_stream() -> Arc<CudaStream> {
let ctx = CudaContext::new(0).expect("CUDA context — is a GPU available?");
ctx.default_stream()
}
fn load_fn(stream: &Arc<CudaStream>, name: &str) -> CudaFunction {
let m = stream
.context()
.load_cubin(TEST_CUBIN.to_vec())
.expect("load sp20_hold_baseline_test_kernel cubin");
m.load_function(name)
.unwrap_or_else(|e| panic!("load {name}: {e}"))
}
/// Helper: run `sp20_per_bar_aux_conf_k2_test_kernel` once with the
/// given logits and return the resulting aux_conf.
fn run_per_bar_aux_conf_k2(logits: [f32; 2]) -> f32 {
let stream = make_stream();
let kernel = load_fn(&stream, "sp20_per_bar_aux_conf_k2_test_kernel");
let logits_buf = unsafe { MappedF32Buffer::new(2) }.expect("alloc logits");
logits_buf.write_from_slice(&logits);
let out_buf = unsafe { MappedF32Buffer::new(1) }.expect("alloc out");
out_buf.write_from_slice(&[0.0_f32]);
unsafe {
stream
.launch_builder(&kernel)
.arg(&logits_buf.dev_ptr)
.arg(&out_buf.dev_ptr)
.launch(cudarc::driver::LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (1, 1, 1),
shared_mem_bytes: 0,
})
.expect("launch sp20_per_bar_aux_conf_k2_test_kernel");
}
stream
.synchronize()
.expect("sync after sp20_per_bar_aux_conf_k2_test_kernel");
out_buf.read_all()[0]
}
/// Helper: run `sp20_sum_hold_baseline_over_trade_test_kernel` once
/// with the given buffer + indexing args and return the resulting sum.
fn run_sum_hold_baseline(
buf: &[f32],
env: i32,
hold_buffer_size: i32,
current_t: i32,
segment_hold_time: f32,
) -> f32 {
let stream = make_stream();
let kernel = load_fn(
&stream,
"sp20_sum_hold_baseline_over_trade_test_kernel",
);
let buf_dev = unsafe { MappedF32Buffer::new(buf.len()) }.expect("alloc buf");
buf_dev.write_from_slice(buf);
let out_buf = unsafe { MappedF32Buffer::new(1) }.expect("alloc out");
out_buf.write_from_slice(&[0.0_f32]);
unsafe {
stream
.launch_builder(&kernel)
.arg(&buf_dev.dev_ptr)
.arg(&env)
.arg(&hold_buffer_size)
.arg(&current_t)
.arg(&segment_hold_time)
.arg(&out_buf.dev_ptr)
.launch(cudarc::driver::LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (1, 1, 1),
shared_mem_bytes: 0,
})
.expect("launch sp20_sum_hold_baseline_over_trade_test_kernel");
}
stream
.synchronize()
.expect("sync after sum_hold_baseline_over_trade");
out_buf.read_all()[0]
}
/// ── Test 1: aux_conf bounds + uniform/peak fixed points ──────────
///
/// `aux_conf = max(softmax(logits)) - 0.5` ∈ [0, 0.5].
///
/// Fixed points:
/// - Equal logits ⇒ peak_softmax = 0.5 ⇒ aux_conf = 0.0 (uniform)
/// - Δlogit → ∞ ⇒ peak_softmax → 1.0 ⇒ aux_conf → 0.5 (saturated)
/// - Δlogit ≈ 0.20067 (= ln(0.55/0.45)) ⇒ aux_conf = 0.05
/// - Δlogit ≈ 2.19722 (= ln(0.90/0.10)) ⇒ aux_conf = 0.40
///
/// These match the spec §4.2 self-stabilizing-property operating
/// points exercised in
/// `sp20_phase1_4_wireup_test::target_hold_pct_behavioral_spot_checks`.
#[test]
#[ignore = "requires GPU"]
fn aux_conf_k2_bounds_and_fixed_points() {
let tol = 1e-4_f32;
// Uniform logits ⇒ aux_conf = 0.0.
let aux_conf = run_per_bar_aux_conf_k2([1.0, 1.0]);
assert!(aux_conf.abs() < tol,
"uniform logits ⇒ aux_conf = 0.0; got {aux_conf}");
// Saturated (Δ = 20 ⇒ peak_softmax ≈ 1.0).
let aux_conf = run_per_bar_aux_conf_k2([0.0, 20.0]);
assert!((aux_conf - 0.5).abs() < tol,
"saturated logits ⇒ aux_conf = 0.5; got {aux_conf}");
// Spec-warmup operating point.
let delta_low = (0.55_f32 / 0.45_f32).ln();
let aux_conf = run_per_bar_aux_conf_k2([0.0, delta_low]);
assert!((aux_conf - 0.05).abs() < 1e-3,
"Δlogit=ln(0.55/0.45) ⇒ aux_conf = 0.05; got {aux_conf}");
// Spec-confident operating point.
let delta_high = (0.90_f32 / 0.10_f32).ln();
let aux_conf = run_per_bar_aux_conf_k2([0.0, delta_high]);
assert!((aux_conf - 0.40).abs() < 1e-3,
"Δlogit=ln(0.90/0.10) ⇒ aux_conf = 0.40; got {aux_conf}");
// Symmetry: order-reversed logits give the same aux_conf.
let a1 = run_per_bar_aux_conf_k2([0.0, delta_high]);
let a2 = run_per_bar_aux_conf_k2([delta_high, 0.0]);
assert!((a1 - a2).abs() < tol,
"symmetric logits ⇒ same aux_conf; got {a1} vs {a2}");
// Non-stable inputs: large abs values with Δ=0 still uniform.
let aux_conf = run_per_bar_aux_conf_k2([1000.0, 1000.0]);
assert!(aux_conf.abs() < tol,
"large equal logits ⇒ aux_conf = 0.0; got {aux_conf}");
}
/// ── Test 2: sum_hold_baseline_over_trade indexing ────────────────
///
/// Buffer = [N=1 env × hold_buffer_size = 5] for clarity. Slots
/// pre-loaded with values matching their slot index for easy
/// arithmetic verification.
///
/// At current_t = 5, segment_hold_time = 3:
/// - Walks backwards from idx (current_t - 1) % 5 = 4.
/// - Reads slots [4, 3, 2] ⇒ sum = 4 + 3 + 2 = 9.
///
/// At current_t = 5, segment_hold_time = 10 (> hold_buffer_size):
/// - Walks backwards but bounded by hold_buffer_size = 5.
/// - Reads slots [4, 3, 2, 1, 0] ⇒ sum = 0 + 1 + 2 + 3 + 4 = 10.
///
/// At segment_hold_time = 0:
/// - Returns 0.0f (defensive guard for empty trade).
///
/// At hold_buffer_size = 0:
/// - Returns 0.0f (defensive guard for empty buffer).
///
/// Wrap-around: current_t = 7, hold_buffer_size = 5 ⇒ start idx =
/// (7 - 1) % 5 = 1; segment_hold_time = 4 walks 1, 0, 4, 3 (wrap).
/// Sum = 1 + 0 + 4 + 3 = 8.
#[test]
#[ignore = "requires GPU"]
fn sum_hold_baseline_over_trade_indexing() {
let tol = 1e-5_f32;
// 1 env × 5-slot buffer. Slot[k] = k.
let buf: Vec<f32> = (0..5).map(|k| k as f32).collect();
// Trade of 3 bars closing at t=5.
let s = run_sum_hold_baseline(&buf, 0, 5, 5, 3.0);
assert!((s - 9.0).abs() < tol,
"current_t=5, segment_hold_time=3 ⇒ sum slots [4,3,2] = 9; got {s}");
// Trade of 10 bars (clamped by buffer size).
let s = run_sum_hold_baseline(&buf, 0, 5, 5, 10.0);
assert!((s - 10.0).abs() < tol,
"current_t=5, segment_hold_time=10 ⇒ all 5 slots = 0+1+2+3+4=10; got {s}");
// Empty trade.
let s = run_sum_hold_baseline(&buf, 0, 5, 5, 0.0);
assert!(s.abs() < tol,
"segment_hold_time=0 ⇒ defensive guard returns 0; got {s}");
// Negative trade (defensive).
let s = run_sum_hold_baseline(&buf, 0, 5, 5, -1.0);
assert!(s.abs() < tol,
"segment_hold_time<0 ⇒ defensive guard returns 0; got {s}");
// Empty buffer.
let s = run_sum_hold_baseline(&buf, 0, 0, 5, 3.0);
assert!(s.abs() < tol,
"hold_buffer_size=0 ⇒ defensive guard returns 0; got {s}");
// Wrap-around test: current_t=7, size=5, hold_time=4.
// Start idx = (7-1) % 5 = 1; walks 1, 0, 4, 3.
let s = run_sum_hold_baseline(&buf, 0, 5, 7, 4.0);
assert!((s - (1.0 + 0.0 + 4.0 + 3.0)).abs() < tol,
"current_t=7, hold_time=4 ⇒ sum slots [1,0,4,3] = 8; got {s}");
}
/// ── Test 3: per-env stride (multiple envs in row-major buffer) ───
///
/// Buffer = [N=3 envs × 4-slot]. Slot[env, k] = env * 100 + k.
/// Layout (row-major):
/// env=0: [ 0, 1, 2, 3]
/// env=1: [100, 101, 102, 103]
/// env=2: [200, 201, 202, 203]
///
/// At env=1, current_t=4, segment_hold_time=2:
/// - Walks backwards from idx (4-1) % 4 = 3.
/// - Reads buf[1*4 + 3] = 103, buf[1*4 + 2] = 102 ⇒ sum = 205.
///
/// Crucially: the buffer reader MUST use the env stride (`env *
/// hold_buffer_size`) to land in env=1's row, not env=0's. A
/// missing stride would return 1+2=3 (env=0 contents), failing
/// this assertion clearly.
#[test]
#[ignore = "requires GPU"]
fn sum_hold_baseline_per_env_stride() {
let tol = 1e-5_f32;
let n_envs = 3;
let size = 4;
let mut buf = vec![0.0_f32; n_envs * size];
for env in 0..n_envs {
for k in 0..size {
buf[env * size + k] = (env * 100 + k) as f32;
}
}
let s = run_sum_hold_baseline(&buf, 1, size as i32, 4, 2.0);
assert!((s - 205.0).abs() < tol,
"env=1, t=4, hold=2 ⇒ buf[1,3]+buf[1,2] = 103+102 = 205; got {s}");
let s = run_sum_hold_baseline(&buf, 2, size as i32, 4, 2.0);
assert!((s - 405.0).abs() < tol,
"env=2, t=4, hold=2 ⇒ buf[2,3]+buf[2,2] = 203+202 = 405; got {s}");
}
/// ── Test 4: full dual-emission shape (Hold action AND non-Hold) ──
///
/// Mirrors the production per-bar emission site (positioned-non-
/// event branch ~line 3650 of experience_kernels.cu).
///
/// Per spec §4.2:
/// per_bar_opp_cost = -aux_conf × cost_scale
/// Path 1 (Hold-only): centered = per_bar_opp_cost - hold_reward_ema
/// Path 2 (always): buf[..] = per_bar_opp_cost (UNcentered)
///
/// Plan §3.2 reference fixture (line ~1306):
/// Fix aux_conf=0.3, cost_scale=0.1, hold_reward_ema=-0.02
/// Bar with action=Hold:
/// per_bar_centered = -0.3*0.1 - (-0.02) = -0.03 + 0.02 = -0.01
/// hold_baseline_buffer = -0.3*0.1 = -0.03 (uncentered)
/// Bar with action=Long:
/// per_bar_centered = 0
/// hold_baseline_buffer = -0.3*0.1 = -0.03 (always written)
///
/// To engineer aux_conf=0.3 ⇒ peak_softmax = 0.8 ⇒ Δlogit =
/// ln(0.8/0.2) = ln(4) ≈ 1.3862944.
#[test]
#[ignore = "requires GPU"]
fn dual_emission_hold_vs_non_hold() {
let tol = 1e-4_f32;
const HOLD_BUFFER_SIZE: i32 = 30;
const HOLD_ACTION_ID: i32 = 1; // DIR_HOLD
const LONG_ACTION_ID: i32 = 2; // DIR_LONG
// Δlogit = ln(0.8/0.2) ⇒ aux_conf = 0.3.
let delta = (0.8_f32 / 0.2_f32).ln();
let logits = [0.0_f32, delta];
let cost_scale = 0.1_f32;
let hold_reward_ema = -0.02_f32;
let current_t = 7_i32;
// Expected:
// per_bar_opp_cost = -0.3 × 0.1 = -0.03
// Hold: centered = -0.03 - (-0.02) = -0.01
// buf[7 % 30 = 7] = -0.03
// Long: centered = 0.0 (Hold-only path)
// buf[7 % 30 = 7] = -0.03 (still written)
let expected_per_bar = -0.03_f32;
let expected_hold_centered = -0.01_f32;
let stream = make_stream();
let kernel = load_fn(&stream, "sp20_dual_emission_test_kernel");
let logits_buf = unsafe { MappedF32Buffer::new(2) }.expect("alloc logits");
logits_buf.write_from_slice(&logits);
let centered_buf = unsafe { MappedF32Buffer::new(1) }.expect("alloc centered");
let buf_buf = unsafe { MappedF32Buffer::new(HOLD_BUFFER_SIZE as usize) }
.expect("alloc hold_baseline_buffer");
// ── Sub-test A: action = Hold ─────────────────────────────────
centered_buf.write_from_slice(&[0.0_f32]);
buf_buf.write_from_slice(&vec![0.0_f32; HOLD_BUFFER_SIZE as usize]);
unsafe {
stream
.launch_builder(&kernel)
.arg(&logits_buf.dev_ptr)
.arg(&cost_scale)
.arg(&hold_reward_ema)
.arg(&HOLD_ACTION_ID)
.arg(&HOLD_ACTION_ID) // hold_action_id
.arg(&current_t)
.arg(&HOLD_BUFFER_SIZE)
.arg(&centered_buf.dev_ptr)
.arg(&buf_buf.dev_ptr)
.launch(cudarc::driver::LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (1, 1, 1),
shared_mem_bytes: 0,
})
.expect("launch dual_emission Hold");
}
stream.synchronize().expect("sync");
let centered = centered_buf.read_all()[0];
let buf_full = buf_buf.read_all();
let buf_slot = buf_full[(current_t % HOLD_BUFFER_SIZE) as usize];
assert!((centered - expected_hold_centered).abs() < tol,
"Hold-action centered: expected {expected_hold_centered}, got {centered}");
assert!((buf_slot - expected_per_bar).abs() < tol,
"Hold-action Path 2 buffer slot: expected {expected_per_bar}, got {buf_slot}");
// Non-target slots untouched (still 0.0).
for k in 0..(HOLD_BUFFER_SIZE as usize) {
if k as i32 == current_t % HOLD_BUFFER_SIZE { continue; }
assert!(buf_full[k].abs() < tol,
"Hold: buffer slot {k} should be untouched 0.0, got {}", buf_full[k]);
}
// ── Sub-test B: action = Long ─────────────────────────────────
centered_buf.write_from_slice(&[999.0_f32]); // sentinel != 0
buf_buf.write_from_slice(&vec![0.0_f32; HOLD_BUFFER_SIZE as usize]);
unsafe {
stream
.launch_builder(&kernel)
.arg(&logits_buf.dev_ptr)
.arg(&cost_scale)
.arg(&hold_reward_ema)
.arg(&LONG_ACTION_ID)
.arg(&HOLD_ACTION_ID)
.arg(&current_t)
.arg(&HOLD_BUFFER_SIZE)
.arg(&centered_buf.dev_ptr)
.arg(&buf_buf.dev_ptr)
.launch(cudarc::driver::LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (1, 1, 1),
shared_mem_bytes: 0,
})
.expect("launch dual_emission Long");
}
stream.synchronize().expect("sync");
let centered = centered_buf.read_all()[0];
let buf_full = buf_buf.read_all();
let buf_slot = buf_full[(current_t % HOLD_BUFFER_SIZE) as usize];
assert!(centered.abs() < tol,
"Long-action Path 1 (Hold-only) ⇒ centered = 0; got {centered}");
assert!((buf_slot - expected_per_bar).abs() < tol,
"Long-action Path 2 ⇒ buffer ALWAYS written; expected {expected_per_bar}, got {buf_slot}");
}
}

View File

@@ -2,7 +2,227 @@
**Status:** Populated during Plan 1 Task 6 (A.5 orphan audit). Updated on every commit per Invariant 7.
## 2026-05-10 — SP20 Phase 2 Task 2.2-fix: post-fix runtime diagnostic infrastructure (read-back + producer test scaffold)
## 2026-05-10 — SP20 Phase 3 Task 3.2: Hold opportunity-cost dual emission (Component 2, atomic)
Lands the per-bar Hold opportunity-cost dual emission at the
`experience_env_step` per-bar branches (positioned-non-event ~line
3650 + flat-non-event ~line 3737), atomically with the trade-close
consumer wiring at the segment_complete branch (~line 3289 — replaces
the Phase 2 Task 2.2 forward-reference placeholder
`hold_baseline = 0.0f`) per `feedback_no_partial_refactor`.
### Spec §4.2 dual-emission contract
```
Per bar i:
aux_conf_i = max(softmax(aux_logits_i)) - 0.5 # K=2
cost_scale = ISV[HOLD_COST_SCALE_INDEX = 513] # adaptive [0.01, 0.5]
per_bar_opp_cost_i = -aux_conf_i × cost_scale
# Path 1 — real reward, fires only on Hold action:
if dir_idx == DIR_HOLD:
r_micro_or_opp_cost += per_bar_opp_cost_i - ISV[HOLD_REWARD_EMA_INDEX = 516]
(centered for Q-target stability)
# Path 2 — counterfactual buffer, ALWAYS recorded:
hold_baseline_buffer[i, current_t % 30] = per_bar_opp_cost_i
(uncentered, consumed by Component 1 trade-close)
At trade close (segment_complete):
hold_baseline = sp20_sum_hold_baseline_over_trade(
hold_baseline_buffer, env=i, hold_buffer_size=30,
current_t, segment_hold_time
) # walks backwards
# min(30, hold_time) bars
# in env i's row
alpha = R_event - hold_baseline # replaces Phase 2 Task 2.2
# placeholder = 0.0f
```
### Replaced sites
1. **Trade-close site** (experience_kernels.cu line ~3289) —
`const float hold_baseline = 0.0f;` Phase 2 Task 2.2 placeholder
replaced with the real `sp20_sum_hold_baseline_over_trade(...)`
call. NULL-tolerant: when `hold_baseline_buffer == NULL` (test
scaffolds), falls back to 0.0f (preserves Phase 2 placeholder
behaviour bit-for-bit).
2. **Per-bar positioned-non-event branch** (line ~3650) — SP18 D-leg
`compute_sp18_hold_opportunity_cost(...)` call replaced with the
dual emission writing `r_micro` (rc[3] component) and
`hold_baseline_buffer[i, t % 30]`.
3. **Per-bar flat-non-event branch** (line ~3737) — SP18 D-leg call
replaced with the dual emission writing `r_opp_cost` (rc[4]
component) and `hold_baseline_buffer[i, t % 30]`.
The SP18 D-leg device function (`compute_sp18_hold_opportunity_cost`
at `trade_physics.cuh:655`) is RETAINED — still called by
`sp18_hold_opp_test_kernel.cu` (the SP18 oracle test surface). Per
`feedback_no_stubs`, only the production callers are migrated; the
helper itself stays for the test surface.
### New device helpers (`sp20_hold_baseline.cuh`)
- `sp20_compute_per_bar_aux_conf_k2(logits)`:
Per-row K=2 peak-confidence above the K=2 uniform mean.
`aux_conf = max(softmax(logits)) - 0.5` ∈ [0, 0.5]. Numerically
stable via row-max subtraction — bit-identical to the formula in
`sp20_stats_compute_kernel.cu` Pass A.
- `sp20_sum_hold_baseline_over_trade(buf, env, size, current_t,
segment_hold_time)`:
Sum the per-env circular buffer over the most recent `min(size,
segment_hold_time)` slots BEFORE `current_t`. Walks backwards from
`(current_t - 1) % size` (the most recent per-bar write — the
trade-close bar's segment_complete branch does NOT write to the
buffer). Modulo arithmetic handles wrap-around correctly.
Both are `__device__ __forceinline__` so the test wrapper kernel can
share them bit-for-bit per `feedback_no_cpu_test_fallbacks`.
### New buffer + reset infrastructure
- `GpuExperienceCollector.hold_baseline_buffer: CudaSlice<f32>`
`[alloc_episodes × HOLD_BASELINE_BUFFER_SIZE = 30]` row-major
f32 device-resident scratch. Allocated next to `alpha_per_env`.
- `pub const HOLD_BASELINE_BUFFER_SIZE: usize = 30` — top-of-file
constant matching spec §4.2 (LOOKAHEAD_HORIZON_MAX). Re-exported
so external tests can reference the same value.
- Registered in `StateResetRegistry` with `FoldReset` category;
reset path mirrors `alpha_per_env`'s `stream.memset_zeros` async
GPU memset (no host buffer to fill).
- Dispatch arm in `training_loop.rs::reset_named_state`.
- Pin-test `sp20_hold_baseline_buffer_registered_fold_reset`
verifies registry entry + category + description anchors.
### New experience_env_step kernel args
Six new args appended to the kernel signature (after the Task 2.2
`alpha_per_env` / `loss_cap_idx` / `alpha_ema_idx` block):
1. `aux_logits_per_env: const float*``[N, K=2]` reuses
`exp_aux_nb_logits_buf` (the same buffer the SP20 stats kernel
reads). Stream-implicit producer→consumer ordering.
2. `hold_baseline_buffer: float*``[N × hold_buffer_size]`.
3. `hold_buffer_size: int` — = 30 (HOLD_BASELINE_BUFFER_SIZE).
4. `aux_k: int` — = 2 (AUX_NEXT_BAR_K). Reserved for future K>2
support; the helper currently hardcodes K=2 per spec §4.2 K=2
AMENDED note.
5. `hold_cost_scale_idx: int` — ISV slot 513. Mirrors the
`loss_cap_idx` / `alpha_ema_idx` pattern (caller passes slot
index so the kernel stays decoupled from SP14 slot-number drift).
6. `hold_reward_ema_idx: int` — ISV slot 516.
All NULL-tolerant: `aux_logits_per_env == NULL` ⇒ both paths skip
(per-bar emission collapses to zero, preserving existing behavior
bit-identically). `hold_baseline_buffer == NULL` ⇒ Path 2 skipped;
Path 1 still fires (centered Hold reward independent of buffer).
Both NULL ⇒ trade-close `hold_baseline` falls back to 0.0f (Phase 2
placeholder behavior).
### HOLD_REWARD_EMA forward-reference (concurrent-agent collision avoidance)
`ISV[HOLD_REWARD_EMA_INDEX]` is updated by `sp20_emas_compute_kernel.cu`
which reads `ema_inputs->per_bar_hold_reward` from
`sp20_aggregate_inputs_kernel.cu`. The aggregate kernel currently
emits `per_bar_hold_reward = 0.0f` (Phase 3.2 forward-reference
placeholder at line 292) — the parallel agent on
`sp20-phase-2-fix` branch is wiring the real producer. Until that
branch lands:
- `ISV[HOLD_REWARD_EMA] = 0.0` (sentinel).
- Path 1 centered = `per_bar_opp_cost - 0.0` = `per_bar_opp_cost`
(centering is a no-op pre-merge).
The Task 3.2 contract is **forward-compatible**: the centering math
references the ISV slot (not a hardcoded 0); when the parallel
branch's aggregate-kernel update lands, EMA starts updating, and the
centering becomes load-bearing automatically. No additional Phase 3.2
work needed post-merge.
### Plan accuracy errata
See `docs/superpowers/plans/2026-05-09-sp19-20-wr-first.md` Phase 3
Errata Gaps 9 (per-env layout), 10 (existing `PS_HOLD_TIME` /
`segment_hold_time` suffices, no new state slot) for the
plan-vs-reality decisions. Spec §4.2 phrasing was implicit
single-thread; reality is per-env-parallel with per-env stride.
### New device kernel: `sp20_hold_baseline_test_kernel.cu`
Test wrapper kernels (3 entry points) for the GPU oracle tests:
- `sp20_per_bar_aux_conf_k2_test_kernel(logits, out)` — drives
`sp20_compute_per_bar_aux_conf_k2` once.
- `sp20_sum_hold_baseline_over_trade_test_kernel(buf, env, size,
current_t, hold_time, out)` — drives the buffer summation helper.
- `sp20_dual_emission_test_kernel(...)` — fixture mirroring the
production per-bar emission site for both Hold-action AND
non-Hold-action paths.
No production callers; cubin built by `crates/ml/build.rs`.
### Tests
`crates/ml/tests/sp20_hold_baseline_test.rs` — 4 GPU oracle tests:
1. `aux_conf_k2_bounds_and_fixed_points` — bounds [0, 0.5],
uniform/saturated/spec-warmup/spec-confident/symmetry fixed
points. Six sub-assertions across one test.
2. `sum_hold_baseline_over_trade_indexing` — basic indexing,
`segment_hold_time > size` clamp, empty-trade defensive guard,
empty-buffer defensive guard, wrap-around modulo.
3. `sum_hold_baseline_per_env_stride` — multiple envs in row-major
buffer; verifies the env stride lands in the right row
(a missing stride would fail the assertion clearly).
4. `dual_emission_hold_vs_non_hold` — full dual-emission contract:
Path 1 fires Hold-only AND Path 2 fires always; non-target
buffer slots remain untouched. Mirrors plan §3.2 reference
fixture (aux_conf=0.3, cost_scale=0.1, hold_reward_ema=-0.02).
### Files modified
| File | Status | Purpose |
|------|--------|---------|
| `crates/ml/src/cuda_pipeline/sp20_hold_baseline.cuh` | NEW | 2 device helpers (Component 2 dual-emission + summation) |
| `crates/ml/src/cuda_pipeline/sp20_hold_baseline_test_kernel.cu` | NEW | 3 test-kernel entry points |
| `crates/ml/src/cuda_pipeline/experience_kernels.cu` | +6 kernel args, +helper +include, +trade-close consumer wiring, ~70 LoC delete + ~70 LoC insert at 2 per-bar branches | Production wiring (Tasks 3.2 dual emission + trade-close consumer) |
| `crates/ml/src/cuda_pipeline/gpu_experience_collector.rs` | +HOLD_BASELINE_BUFFER_SIZE const, +field, +alloc, +6 kernel-arg passes | Per-env buffer infrastructure + production launch wiring |
| `crates/ml/src/trainers/dqn/state_reset_registry.rs` | +entry + invariant test | FoldReset for `hold_baseline_buffer` |
| `crates/ml/src/trainers/dqn/trainer/training_loop.rs` | +match arm | `reset_named_state` dispatch |
| `crates/ml/build.rs` | +1 entry | sp20_hold_baseline_test_kernel cubin |
| `crates/ml/tests/sp20_hold_baseline_test.rs` | NEW | 4 GPU oracle tests + 1 helper module |
| `docs/dqn-wire-up-audit.md` | This entry | Audit log |
### Verification
```
SQLX_OFFLINE=true cargo build -p ml # cubin compile
SQLX_OFFLINE=true cargo check -p ml --tests --features cuda # tests compile
SQLX_OFFLINE=true cargo test -p ml --lib sp20 # 21/21 lib tests
SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 cargo test -p ml \
--test sp20_hold_baseline_test --features cuda \
-- --ignored --nocapture # 4 GPU oracle tests
```
### Phase 3 → Phase 4 forward references
- Phase 3.4: replay buffer schema — per-bar `aux_conf` field for
the Phase 5 Aux→Q gate (lands separately as the next atomic
commit on this branch).
- Phase 4: n-step credit distributor consumes `R_used = alpha -
alpha_ema` (Component 1 output) — the `alpha` produced HERE in
Phase 3.2 (= `R_event - sum_hold_baseline_over_trade`) becomes
Phase 4's input.
## 2026-05-10 — SP20 Phase 3 Task 3.3: target_hold_pct behavioral spot-checks (test-only)
Adds infrastructure for verifying the SP20 Phase 2 Task 2.2-fix
(`is_win_per_env`) producer side at runtime. The consumer-side