Closes the Phase 3.2 forward-reference loop in the SP20 aggregator.
Previously `out_inputs->per_bar_hold_reward = 0.0f` was hardcoded; the
per-bar Hold opportunity-cost producer existed
(experience_kernels.cu:3823 — `per_bar_opp_cost = -aux_conf × cost_scale`)
and wrote to `hold_baseline_buffer` and `r_micro` directly, but never
reached HOLD_REWARD_EMA. Result: HOLD_REWARD_EMA frozen at sentinel 0.0
across all observed epochs; the SP20 reward centering loop
(`r_micro += per_bar_opp_cost - HOLD_REWARD_EMA`) stayed uncentered,
biasing the policy's reward signal away from zero-mean.
T3.3 wireup mirrors the T2.2 alpha refactor: a per-env scratch buffer
that the producer writes at every step (alongside the existing
`hold_baseline_buffer` write), and the aggregator reads at the same
step. Sums opp_cost over Hold-direction envs only; emits the mean as
`per_bar_hold_reward`. The HOLD_REWARD_EMA's gate (`hold_fraction > 0.5f`
from T3.2) preserves the strict-majority semantic from before.
Atomic across producer site, kernel signature, aggregator, launcher,
collector, and tests (per feedback_no_partial_refactor):
- experience_kernels.cu — new `float* per_bar_opp_cost_per_env`
kernel arg, NULL-tolerant write at line ~3823.
- sp20_aggregate_inputs_kernel.cu — new arg, 6th shmem stripe
(`sh_opp_cost_sum`), per-thread Hold-gated accumulation, tree
reduction extended to 6 stripes, output `per_bar_hold_reward =
opp_cost_sum / hold_count` when hold_count > 0 else 0.
- sp20_aggregate_inputs.rs — launcher signature, dynamic_shmem_bytes
5→6 stripes, doc table, internal test.
- gpu_experience_collector.rs — new `per_bar_opp_cost_per_env:
CudaSlice<f32>` field, alloc, struct construction, kernel-arg
pass at both experience_env_step and sp20_aggregate_inputs
launches (raw_ptr).
- sp20_aggregate_inputs_test.rs — helper renamed
`run_kernel_with_is_win_and_opp_cost`, 4 existing sites pass
NULL, 2 NEW oracle tests verifying real producer + NULL fallback.
- sp20_phase1_4_wireup_test.rs — NULL fallback at the wireup site;
HOLD_REWARD_EMA-stays-at-zero assertion remains valid.
Verification:
- cargo check -p ml --tests: passes (warnings only)
- cargo test -p ml --test sp20_aggregate_inputs_test --features cuda
-- --ignored: 12/12 GPU oracle tests pass on RTX 3050 Ti, including
both new T3.3 tests (per_bar_hold_reward_means_over_hold_envs_only,
null_per_bar_opp_cost_emits_zero).
- cargo test -p ml --test sp20_phase1_4_wireup_test --features cuda
-- --ignored: 2/2 pass under the new NULL-tolerant contract.
Plan reference: docs/plans/2026-05-10-sp21-train-eval-coherence-isv-defrost.md
Tier 3 status: T3.1 ✓, T3.2 ✓, T3.3 ✓ (this commit), T3.4 withdrawn,
T3.5 cascade-pending, T3.6 withdrawn.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
379 lines
17 KiB
Rust
379 lines
17 KiB
Rust
//! SP20 Phase 1.4 (2026-05-09, Path C) — fused-producer chain integration test.
|
|
//!
|
|
//! Direct end-to-end exercise of the 4-kernel chain that the
|
|
//! `GpuExperienceCollector` invokes per rollout step:
|
|
//!
|
|
//! Stats (Kernel 3) → Aggregate (Path C) → EMAs (Kernel 1) → Controllers (Kernel 2)
|
|
//!
|
|
//! Constructs synthetic per-env arrays + aux-head outputs in mapped-pinned
|
|
//! buffers, fires the 4 launches in sequence on a single stream, and
|
|
//! asserts that the 10 SP20 ISV slots populate with sensible
|
|
//! formula-derived values.
|
|
//!
|
|
//! Per `pearl_tests_must_prove_not_lock_observations` the assertions are
|
|
//! invariants (slot is non-sentinel, bound-respecting, monotonic in the
|
|
//! expected direction) rather than locked numerical values.
|
|
//!
|
|
//! Run on a GPU host:
|
|
//!
|
|
//! SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 \
|
|
//! cargo test -p ml --test sp20_phase1_4_wireup_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};
|
|
use ml::cuda_pipeline::mapped_pinned::{MappedF32Buffer, MappedI32Buffer};
|
|
use ml::cuda_pipeline::sp14_isv_slots::{
|
|
ALPHA_EMA_INDEX, AUX_CONF_THRESHOLD_INDEX, AUX_GATE_TEMP_INDEX,
|
|
HOLD_COST_SCALE_INDEX, HOLD_PCT_EMA_INDEX, HOLD_REWARD_EMA_INDEX,
|
|
LOSS_CAP_INDEX, N_STEP_INDEX, TARGET_HOLD_PCT_INDEX, WR_EMA_INDEX,
|
|
};
|
|
use ml::cuda_pipeline::sp20_aggregate_inputs::launch_sp20_aggregate_inputs;
|
|
use ml::cuda_pipeline::sp20_controllers_compute::launch_sp20_controllers_compute;
|
|
use ml::cuda_pipeline::sp20_emas_compute::{
|
|
launch_sp20_emas_compute, EMA_INTERNAL_SLOT_COUNT, OBS_COUNT_SLOTS,
|
|
SP20_EMA_INPUTS_F32_LEN,
|
|
};
|
|
use ml::cuda_pipeline::sp20_stats_compute::{
|
|
launch_sp20_stats_compute, AUX_K_CLASSES,
|
|
};
|
|
|
|
/// ISV slot count: cover up to slot 519.
|
|
const ISV_LEN: usize = 520;
|
|
|
|
/// Production action encoding constants (mirror `state_layout.cuh`).
|
|
const DIR_DIVISOR: i32 = 27; // 3 * 3 * 3 (mag * ord * urg)
|
|
const HOLD_ACTION_ID: i32 = 1;
|
|
|
|
fn encode_dir(dir: i32) -> i32 {
|
|
dir * DIR_DIVISOR
|
|
}
|
|
|
|
const SP20_STATS_CUBIN: &[u8] = include_bytes!(concat!(
|
|
env!("OUT_DIR"),
|
|
"/sp20_stats_compute_kernel.cubin"
|
|
));
|
|
const SP20_AGGREGATE_CUBIN: &[u8] = include_bytes!(concat!(
|
|
env!("OUT_DIR"),
|
|
"/sp20_aggregate_inputs_kernel.cubin"
|
|
));
|
|
const SP20_EMAS_CUBIN: &[u8] = include_bytes!(concat!(
|
|
env!("OUT_DIR"),
|
|
"/sp20_emas_compute_kernel.cubin"
|
|
));
|
|
const SP20_CONTROLLERS_CUBIN: &[u8] = include_bytes!(concat!(
|
|
env!("OUT_DIR"),
|
|
"/sp20_controllers_compute_kernel.cubin"
|
|
));
|
|
|
|
fn make_stream() -> Arc<CudaStream> {
|
|
let ctx = CudaContext::new(0).expect("CUDA context — is a GPU available?");
|
|
ctx.default_stream()
|
|
}
|
|
|
|
fn load_kernel(stream: &Arc<CudaStream>, cubin: &[u8], name: &str) -> CudaFunction {
|
|
let m = stream
|
|
.context()
|
|
.load_cubin(cubin.to_vec())
|
|
.unwrap_or_else(|e| panic!("load {name} cubin: {e}"));
|
|
m.load_function(name)
|
|
.unwrap_or_else(|e| panic!("load {name} function: {e}"))
|
|
}
|
|
|
|
/// Run the full 4-kernel chain once with the given synthetic inputs.
|
|
/// Returns the final ISV state after the chain settles.
|
|
fn run_chain_once(
|
|
n_envs: usize,
|
|
aux_logits: &[f32],
|
|
trade_close: &[i32],
|
|
step_ret: &[f32],
|
|
hold_at_exit: &[f32],
|
|
actions: &[i32],
|
|
aux_dir_acc: f32,
|
|
) -> Vec<f32> {
|
|
let stream = make_stream();
|
|
let stats_kernel = load_kernel(&stream, SP20_STATS_CUBIN, "sp20_stats_compute_kernel");
|
|
let agg_kernel = load_kernel(&stream, SP20_AGGREGATE_CUBIN, "sp20_aggregate_inputs_kernel");
|
|
let emas_kernel = load_kernel(&stream, SP20_EMAS_CUBIN, "sp20_emas_compute_kernel");
|
|
let ctrl_kernel = load_kernel(&stream, SP20_CONTROLLERS_CUBIN, "sp20_controllers_compute_kernel");
|
|
|
|
// ── ISV bus ──
|
|
let isv = unsafe { MappedF32Buffer::new(ISV_LEN) }.expect("alloc isv");
|
|
isv.write_from_slice(&vec![0.0_f32; ISV_LEN]);
|
|
|
|
// ── Stats inputs/outputs ──
|
|
assert_eq!(aux_logits.len(), n_envs * AUX_K_CLASSES,
|
|
"aux_logits must be [n_envs * K=2]");
|
|
let aux_logits_buf = unsafe {
|
|
MappedF32Buffer::new(n_envs * AUX_K_CLASSES)
|
|
}.expect("alloc aux_logits");
|
|
aux_logits_buf.write_from_slice(aux_logits);
|
|
let stats_out = unsafe { MappedF32Buffer::new(2) }.expect("alloc stats_out");
|
|
stats_out.write_from_slice(&[0.0_f32, 0.0_f32]);
|
|
|
|
// ── Aggregation inputs ──
|
|
let tc_buf = unsafe { MappedI32Buffer::new(n_envs) }.expect("alloc tc");
|
|
tc_buf.write_from_slice(trade_close);
|
|
let sr_buf = unsafe { MappedF32Buffer::new(n_envs) }.expect("alloc sr");
|
|
sr_buf.write_from_slice(step_ret);
|
|
let he_buf = unsafe { MappedF32Buffer::new(n_envs) }.expect("alloc he");
|
|
he_buf.write_from_slice(hold_at_exit);
|
|
let act_buf = unsafe { MappedI32Buffer::new(n_envs) }.expect("alloc act");
|
|
act_buf.write_from_slice(actions);
|
|
let dir_acc_buf = unsafe { MappedF32Buffer::new(1) }.expect("alloc dir_acc");
|
|
dir_acc_buf.write_from_slice(&[aux_dir_acc]);
|
|
|
|
// ── Aggregation output / EMA input struct (9 f32 byte-aliased) ──
|
|
let ema_inputs = unsafe { MappedF32Buffer::new(SP20_EMA_INPUTS_F32_LEN) }
|
|
.expect("alloc ema_inputs");
|
|
ema_inputs.write_from_slice(&vec![0.0_f32; SP20_EMA_INPUTS_F32_LEN]);
|
|
|
|
// ── EMA scratch ──
|
|
let internal = unsafe { MappedF32Buffer::new(EMA_INTERNAL_SLOT_COUNT) }
|
|
.expect("alloc internal");
|
|
internal.write_from_slice(&vec![0.0_f32; EMA_INTERNAL_SLOT_COUNT]);
|
|
let obs_count = unsafe { MappedI32Buffer::new(OBS_COUNT_SLOTS) }
|
|
.expect("alloc obs_count");
|
|
obs_count.write_from_slice(&vec![0_i32; OBS_COUNT_SLOTS]);
|
|
|
|
// ── Launch chain ──
|
|
unsafe {
|
|
launch_sp20_stats_compute(
|
|
&stream, &stats_kernel,
|
|
aux_logits_buf.dev_ptr,
|
|
stats_out.dev_ptr,
|
|
n_envs as i32,
|
|
).expect("launch sp20_stats_compute");
|
|
|
|
let stats_p50 = stats_out.dev_ptr;
|
|
let stats_std = stats_out.dev_ptr + std::mem::size_of::<f32>() as u64;
|
|
launch_sp20_aggregate_inputs(
|
|
&stream, &agg_kernel,
|
|
tc_buf.dev_ptr,
|
|
sr_buf.dev_ptr,
|
|
he_buf.dev_ptr,
|
|
act_buf.dev_ptr,
|
|
/* env_stride = */ 1,
|
|
stats_p50,
|
|
stats_std,
|
|
dir_acc_buf.dev_ptr,
|
|
/* alpha_per_env_dev = */ 0, // SP20 Phase 2 Task 2.2 — Phase 1.4
|
|
// wireup test stays NULL; the alpha
|
|
// producer is exercised at the
|
|
// segment_complete site in the full
|
|
// collector path. Tests in this file
|
|
// assert the placeholder contract
|
|
// (alpha_ema stays at sentinel 0.0).
|
|
/* is_win_per_env_dev = */ 0, // SP20 Phase 2 Task 2.2-fix
|
|
// (2026-05-10) — wireup test stays
|
|
// NULL; the kernel falls back to
|
|
// the `sr > 0` legacy predicate so
|
|
// existing wireup-test assertions
|
|
// (e.g. WR_EMA = 1.0 with one
|
|
// winning trade at step_ret > 0)
|
|
// remain valid.
|
|
/* per_bar_opp_cost_per_env_dev = */ 0, // SP21 T3.3 — wireup test stays
|
|
// NULL; consumer falls back to
|
|
// per_bar_hold_reward = 0.0f
|
|
// matching pre-T3.3 contract.
|
|
n_envs as i32,
|
|
DIR_DIVISOR,
|
|
HOLD_ACTION_ID,
|
|
ema_inputs.dev_ptr,
|
|
).expect("launch sp20_aggregate_inputs");
|
|
|
|
launch_sp20_emas_compute(
|
|
&stream, &emas_kernel,
|
|
isv.dev_ptr,
|
|
ema_inputs.dev_ptr,
|
|
internal.dev_ptr,
|
|
obs_count.dev_ptr,
|
|
).expect("launch sp20_emas_compute");
|
|
|
|
launch_sp20_controllers_compute(
|
|
&stream, &ctrl_kernel,
|
|
isv.dev_ptr,
|
|
internal.dev_ptr,
|
|
).expect("launch sp20_controllers_compute");
|
|
}
|
|
stream.synchronize().expect("sync after sp20 chain");
|
|
isv.read_all()
|
|
}
|
|
|
|
/// Smoke: one rollout step with mid-confidence aux + a winning trade
|
|
/// closure at one env should populate every SP20 ISV slot with
|
|
/// non-sentinel, bound-respecting values.
|
|
#[test]
|
|
#[ignore = "requires GPU"]
|
|
fn one_step_chain_populates_all_10_isv_slots() {
|
|
let n_envs = 8;
|
|
// Mid-confidence aux logits: each row [logit_down, logit_up] with
|
|
// a clear winner (e.g. up=1.0, down=0.0 ⇒ softmax up ≈ 0.73).
|
|
// peak_softmax ≈ 0.73 ⇒ aux_conf ≈ 0.73 - 0.5 = 0.23.
|
|
let mut aux_logits = Vec::with_capacity(n_envs * AUX_K_CLASSES);
|
|
for _ in 0..n_envs {
|
|
aux_logits.push(0.0); // logit_down
|
|
aux_logits.push(1.0); // logit_up
|
|
}
|
|
|
|
// One env closes a winning trade at duration 5.
|
|
let mut trade_close = vec![0; n_envs];
|
|
let mut step_ret = vec![0.0_f32; n_envs];
|
|
let mut hold_at_exit = vec![0.0_f32; n_envs];
|
|
trade_close[3] = 1;
|
|
step_ret[3] = 0.4;
|
|
hold_at_exit[3] = 5.0;
|
|
|
|
// Half the envs picked Hold (4 of 8 = tie ⇒ action_is_hold = 0).
|
|
// Bump to 5 to cross strict-majority threshold and exercise the
|
|
// HOLD_PCT_EMA fire-on-Hold path.
|
|
let mut actions = vec![encode_dir(2); n_envs]; // most envs Long
|
|
for i in 0..5 {
|
|
actions[i] = encode_dir(HOLD_ACTION_ID);
|
|
}
|
|
|
|
let isv = run_chain_once(
|
|
n_envs, &aux_logits, &trade_close, &step_ret, &hold_at_exit,
|
|
&actions, /* aux_dir_acc = */ 0.62,
|
|
);
|
|
|
|
// ── ISV invariants ──
|
|
|
|
// ALPHA_EMA: aggregation kernel sees `alpha_per_env_dev = 0`
|
|
// (NULL in this test scaffold — the full SP20 reward producer
|
|
// is exercised at the `experience_env_step::segment_complete`
|
|
// site, not in this Phase 1.4 wireup test). The kernel falls
|
|
// back to `alpha = 0.0`, first-observation REPLACE on a closed
|
|
// trade ⇒ ALPHA_EMA stays at 0.0. **This validates the SP20
|
|
// Phase 2 Task 2.2 NULL-tolerance contract** — the
|
|
// aggregation kernel still emits 0.0 when the alpha producer
|
|
// is unwired, preserving the Phase 1.4 placeholder behavior
|
|
// for tests that don't exercise the reward producer.
|
|
assert_eq!(isv[ALPHA_EMA_INDEX], 0.0,
|
|
"ALPHA_EMA stays at 0.0 with NULL alpha_per_env producer (Task 2.2 NULL contract)");
|
|
|
|
// WR_EMA: 1 win out of 1 close ⇒ win_fraction = 1.0, first-obs
|
|
// replace ⇒ WR_EMA = 1.0. SP21 T3.1: aggregator emits exact
|
|
// fraction (was binary majority).
|
|
assert_eq!(isv[WR_EMA_INDEX], 1.0,
|
|
"WR_EMA = 1.0 after one winning close (1 win / 1 closed = 1.0)");
|
|
|
|
// HOLD_PCT_EMA: 5 of 8 envs picked Hold ⇒ hold_fraction = 5/8 =
|
|
// 0.625, first-obs replace ⇒ HOLD_PCT_EMA = 0.625. SP21 T3.2:
|
|
// aggregator emits exact fraction (was binary majority indicator
|
|
// → 1.0). The fractional value lets the downstream HOLD_COST_SCALE
|
|
// controller make graded decisions instead of the cascade-
|
|
// pinning-at-floor that the binary signal produced.
|
|
let hold_pct = isv[HOLD_PCT_EMA_INDEX];
|
|
assert!(
|
|
(hold_pct - 0.625).abs() < 1e-6,
|
|
"HOLD_PCT_EMA = 0.625 (5/8 of envs Hold); got {hold_pct}",
|
|
);
|
|
|
|
// HOLD_REWARD_EMA: per_bar_hold_reward = 0.0 (Phase 3.2 fwd
|
|
// ref); fired on Hold ⇒ EMA = 0.0.
|
|
assert_eq!(isv[HOLD_REWARD_EMA_INDEX], 0.0,
|
|
"HOLD_REWARD_EMA stays at 0.0 (Phase 3.2 forward ref)");
|
|
|
|
// LOSS_CAP: WR_EMA = 1.0 > 0.55 ⇒ ramp clamped at 1.0 ⇒
|
|
// LOSS_CAP = -1 - 1 = -2.0.
|
|
let lc = isv[LOSS_CAP_INDEX];
|
|
assert!((lc - (-2.0)).abs() < 1e-6,
|
|
"LOSS_CAP = -2.0 with WR_EMA = 1.0; got {lc}");
|
|
|
|
// N_STEP: trade_duration_ema = 5 (first-obs replace), then
|
|
// round to 5; clamp [1, 30] ⇒ N_STEP = 5.
|
|
let n = isv[N_STEP_INDEX];
|
|
assert!((n - 5.0).abs() < 1e-6,
|
|
"N_STEP = 5 with trade_duration = 5; got {n}");
|
|
|
|
// AUX_CONF_THRESHOLD: aux_dir_acc_ema = 0.62 (first-obs
|
|
// replace) ⇒ raw = 0.62 - 0.50 = 0.12 ⇒ clamp [0.01, 0.20] ⇒
|
|
// 0.12.
|
|
let act = isv[AUX_CONF_THRESHOLD_INDEX];
|
|
assert!((act - 0.12).abs() < 1e-5,
|
|
"AUX_CONF_THRESHOLD = 0.12 with dir_acc = 0.62; got {act}");
|
|
|
|
// AUX_GATE_TEMP: aux_std_ema is the std of aux_conf rows; with
|
|
// identical logits per row, aux_conf is identical so std = 0;
|
|
// floor at 0.01.
|
|
let agt = isv[AUX_GATE_TEMP_INDEX];
|
|
assert!((agt - 0.01).abs() < 1e-6,
|
|
"AUX_GATE_TEMP floor = 0.01 with identical aux logits; got {agt}");
|
|
|
|
// TARGET_HOLD_PCT: aux_p50_ema is the kernel's median of
|
|
// aux_conf rows (sp4-histogram quantization). With identical
|
|
// logits per row it lands somewhere in [0, 0.5]; the controller
|
|
// formula then maps that to TARGET_HOLD_PCT in [0.1, 0.8].
|
|
// We assert bound-respecting per
|
|
// `pearl_tests_must_prove_not_lock_observations` rather than
|
|
// an observed numerical value (the histogram bin granularity
|
|
// makes the exact value implementation-specific).
|
|
let thp = isv[TARGET_HOLD_PCT_INDEX];
|
|
assert!(thp >= 0.1 - 1e-6 && thp <= 0.8 + 1e-6,
|
|
"TARGET_HOLD_PCT must be in [0.1, 0.8]; got {thp}");
|
|
|
|
// HOLD_COST_SCALE: prev = 0 (constructor), hold_pct_ema = 1.0 >
|
|
// tgt + 0.05 ⇒ ramp up: 0 * 1.05 = 0; final clamp [0.01, 0.5]
|
|
// ⇒ 0.01 (floor). Verifies the deadband / final-clamp wiring
|
|
// even at sentinel-zero start.
|
|
let hcs = isv[HOLD_COST_SCALE_INDEX];
|
|
assert!(hcs >= 0.01 - 1e-6 && hcs <= 0.5 + 1e-6,
|
|
"HOLD_COST_SCALE in [0.01, 0.5]; got {hcs}");
|
|
}
|
|
|
|
/// SP20 Phase 2 Task 2.2 contract — when the aggregation kernel is
|
|
/// invoked with `alpha_per_env_dev = 0` (NULL, this test scaffold's
|
|
/// configuration) AND the per-bar-hold-reward producer is still in
|
|
/// Phase 3.2 forward-reference, both EMAs stay at sentinel 0.0
|
|
/// regardless of any input combinations. Validates the
|
|
/// NULL-tolerance contract of the aggregation kernel post-Task-2.2:
|
|
/// downstream tests that don't wire the SP20 reward producer (e.g.
|
|
/// this Phase 1.4 wireup smoke) preserve the Phase 1.4 ALPHA_EMA
|
|
/// placeholder behavior.
|
|
///
|
|
/// HOLD_REWARD_EMA stays at 0.0 because Phase 3.2 lands the per-bar
|
|
/// `r_per_bar = -aux_conf * cost_scale` producer; the aggregation
|
|
/// kernel writes 0.0 for `per_bar_hold_reward` until then.
|
|
///
|
|
/// **Pinned forward refs locked here**:
|
|
/// - ALPHA_EMA bound to NULL-alpha-producer scaffold contract.
|
|
/// - HOLD_REWARD_EMA bound to Phase 3.2 placeholder.
|
|
#[test]
|
|
#[ignore = "requires GPU"]
|
|
fn alpha_and_hold_reward_emas_stay_at_zero_with_null_producers() {
|
|
let n_envs = 4;
|
|
// 4 closing wins per call, 4 Hold actions per call
|
|
let trade_close = vec![1; n_envs];
|
|
let step_ret = vec![0.5; n_envs];
|
|
let hold_at_exit = vec![10.0; n_envs];
|
|
let actions = vec![encode_dir(HOLD_ACTION_ID); n_envs];
|
|
let aux_logits = vec![0.5_f32; n_envs * AUX_K_CLASSES];
|
|
|
|
// Single chain run is enough — `run_chain_once` passes
|
|
// alpha_per_env_dev=0 (NULL), so the aggregation kernel falls
|
|
// back to `alpha = 0.0`; first-obs REPLACE on 0.0 lands the
|
|
// EMA at 0.0.
|
|
let isv = run_chain_once(
|
|
n_envs, &aux_logits, &trade_close, &step_ret, &hold_at_exit,
|
|
&actions, 0.99,
|
|
);
|
|
|
|
assert_eq!(isv[ALPHA_EMA_INDEX], 0.0,
|
|
"ALPHA_EMA stays at 0.0 with NULL alpha_per_env producer \
|
|
(Task 2.2 NULL-tolerance contract). The full SP20 reward \
|
|
producer at experience_env_step::segment_complete is NOT \
|
|
exercised in this Phase 1.4 wireup test.");
|
|
assert_eq!(isv[HOLD_REWARD_EMA_INDEX], 0.0,
|
|
"HOLD_REWARD_EMA must be 0.0 — Phase 3.2 forward ref \
|
|
placeholder. Aggregation kernel writes 0.0 until \
|
|
Component 2 lands the per-bar producer.");
|
|
}
|
|
}
|