Files
foxhunt/crates/ml/tests/sp20_controllers_compute_test.rs
jgrusewski 5ded1cb4b9 feat(sp20): Phase 1.3 sp20_controllers_compute kernel
Component 5 / Kernel 2 of the SP20 fused-producer chain — the
deterministic derivation step. Reads the EMAs Phase 1.2's
sp20_emas_compute wrote (4 ISV + 4 internal scratch slots) and
derives 6 controller outputs into ISV slots:

  - LOSS_CAP (510)            = -1 - clamp((wr-0.50)/0.05, 0, 1)
  - HOLD_COST_SCALE (513)     two-sided ramp around TARGET_HOLD_PCT ±0.05
  - TARGET_HOLD_PCT (514)     = clamp(0.8 - aux_p50_ema*1.5, 0.1, 0.8)
  - N_STEP (517)              = clamp(round(trade_dur_ema), 1, 30) (f32)
  - AUX_CONF_THRESHOLD (518)  = clamp(aux_dir_acc_ema-0.50, 0.01, 0.20)
  - AUX_GATE_TEMP (519)       = max(aux_conf_std_ema, 0.01)

TARGET_HOLD_PCT computed before HOLD_COST_SCALE because the
HOLD_COST_SCALE controller reads tgt; implemented as a local f32
written to ISV then re-used for the controller (no redundant ISV
read-back). HOLD_COST_SCALE is the only output that READS its own
previous ISV value (in-place update); deadband path writes the
unchanged previous value verbatim per feedback_no_stubs.

Single-block, single-thread kernel — captureable in the per-step
CUDA Graph per pearl_no_host_branches_in_captured_graph. ISV +
internal pointers are mapped-pinned device pointers (existing
buffers from Phase 1.2); kernel emits __threadfence_system() for
PCIe-visible coherence.

Pearls + invariants honoured:
  - pearl_controller_anchors_isv_driven: every output's anchor /
    target / cap derives from EMAs; only spec-frozen ramp / clamp
    parameters are compile-time constants.
  - feedback_isv_for_adaptive_bounds: these 6 outputs ARE the
    adaptive bounds.
  - feedback_no_atomicadd, feedback_no_cpu_compute_strict,
    feedback_no_htod_htoh_only_mapped_pinned, feedback_no_stubs.
  - feedback_no_partial_refactor: kernel + launcher + tests + build
    entry land atomically; production wire-up lands in Phase 1.4.
  - pearl_tests_must_prove_not_lock_observations: 7 GPU oracle
    tests assert clamp boundaries, formula correctness, and
    bidirectional ramp behavior — NOT specific observed values.

Tests (all #[ignore = "requires GPU"], pass on RTX 3050 Ti sm_86):
  1. loss_cap_ramp_boundaries — 4 wr_ema sweeps incl. clamps.
  2. n_step_bounds — round + clamp [1, 30].
  3. aux_conf_threshold_bounds — clamp [0.01, 0.20].
  4. aux_gate_temp_floor — max(std, 0.01).
  5. target_hold_pct_inverse_relation — 0.8 − p50*1.5 with clamps.
  6. hold_cost_scale_two_sided_ramp — up/down/deadband + clamps.
  7. all_six_outputs_written_in_one_launch — guards missed writes.

Plus 3 launcher unit tests (slot range, slot uniqueness, ISV input
slot range).

Verification:
  SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 cargo check -p ml --features cuda
  SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 cargo test -p ml \
    --test sp20_controllers_compute_test --features cuda \
    -- --ignored --nocapture
  → 7/7 GPU oracle tests pass; 3/3 launcher unit tests pass.

Phase 1.4 will land the production caller atomically alongside
Kernel 1 + Kernel 3 launches on the same stream in order
Stats → EMAs → Controllers per the SP20 design.
2026-05-09 19:09:06 +02:00

433 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! SP20 Phase 1.3 (2026-05-09) — `sp20_controllers_compute_kernel` GPU oracle tests.
//!
//! Verifies the 6 derived controller producer kernel (Component 5 / Kernel 2
//! of the SP20 design) reads the EMAs produced by Kernel 1 and Kernel 3
//! and writes 6 ISV slots:
//!
//! - `LOSS_CAP_INDEX` (510) — ramp on `WR_EMA`
//! - `HOLD_COST_SCALE_INDEX` (513) — two-sided ramp on `HOLD_PCT_EMA` vs
//! `TARGET_HOLD_PCT`
//! - `TARGET_HOLD_PCT_INDEX` (514) — inverse-relation on `aux_conf_p50_ema`
//! - `N_STEP_INDEX` (517) — clamp(round(`trade_duration_ema`))
//! - `AUX_CONF_THRESHOLD_INDEX` (518) — clamp(`aux_dir_acc_ema` 0.50)
//! - `AUX_GATE_TEMP_INDEX` (519) — max(`aux_conf_std_ema`, 0.01)
//!
//! Tests assert clamp boundaries, formula correctness, and bidirectional
//! ramp behavior per `pearl_tests_must_prove_not_lock_observations` — never
//! observed-from-a-regression-run values.
//!
//! Run on a GPU host:
//!
//! SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 \
//! cargo test -p ml --test sp20_controllers_compute_test --features cuda \
//! -- --ignored --nocapture
#![allow(clippy::tests_outside_test_module)]
#[cfg(feature = "cuda")]
#[allow(unsafe_code)] // CUDA kernel launch + mapped-pinned memory.
mod gpu {
use std::sync::Arc;
use cudarc::driver::{CudaContext, CudaFunction, CudaStream};
use ml::cuda_pipeline::mapped_pinned::MappedF32Buffer;
use ml::cuda_pipeline::sp14_isv_slots::{
AUX_CONF_THRESHOLD_INDEX, AUX_GATE_TEMP_INDEX, HOLD_COST_SCALE_INDEX,
HOLD_PCT_EMA_INDEX, LOSS_CAP_INDEX, N_STEP_INDEX, TARGET_HOLD_PCT_INDEX,
WR_EMA_INDEX,
};
use ml::cuda_pipeline::sp20_controllers_compute::launch_sp20_controllers_compute;
use ml::cuda_pipeline::sp20_emas_compute::{
EMA_INTERNAL_AUX_DIR_ACC, EMA_INTERNAL_AUX_P50, EMA_INTERNAL_AUX_STD,
EMA_INTERNAL_SLOT_COUNT, EMA_INTERNAL_TRADE_DURATION,
};
/// Total ISV slot count for the test allocator. Just enough to cover
/// the highest SP20 slot (519).
const ISV_TEST_LEN: usize = 520;
const SP20_CONTROLLERS_COMPUTE_CUBIN: &[u8] = include_bytes!(concat!(
env!("OUT_DIR"),
"/sp20_controllers_compute_kernel.cubin"
));
fn make_test_stream() -> Arc<CudaStream> {
let ctx = CudaContext::new(0).expect("CUDA context — is a GPU available?");
ctx.default_stream()
}
fn load_sp20_controllers_compute(stream: &Arc<CudaStream>) -> CudaFunction {
let module = stream
.context()
.load_cubin(SP20_CONTROLLERS_COMPUTE_CUBIN.to_vec())
.expect("load sp20_controllers_compute_kernel cubin");
module
.load_function("sp20_controllers_compute_kernel")
.expect("load sp20_controllers_compute_kernel function")
}
/// Test harness: seeds ISV[WR_EMA, HOLD_PCT_EMA, HOLD_COST_SCALE] +
/// internal[trade_dur, p50, std, dir_acc] with caller-supplied values,
/// then fires one launch and returns the post-launch ISV state.
///
/// `prev_hold_cost_scale` — the existing HOLD_COST_SCALE value (the
/// kernel's two-sided controller reads this in-place).
fn run_one_launch(
wr_ema: f32,
hold_pct_ema: f32,
prev_hold_cost_scale: f32,
trade_duration_ema: f32,
aux_p50_ema: f32,
aux_std_ema: f32,
aux_dir_acc_ema: f32,
) -> Vec<f32> {
let stream = make_test_stream();
let kernel = load_sp20_controllers_compute(&stream);
let isv = unsafe { MappedF32Buffer::new(ISV_TEST_LEN) }.expect("alloc isv");
let mut isv_init = vec![0.0_f32; ISV_TEST_LEN];
isv_init[WR_EMA_INDEX] = wr_ema;
isv_init[HOLD_PCT_EMA_INDEX] = hold_pct_ema;
isv_init[HOLD_COST_SCALE_INDEX] = prev_hold_cost_scale;
isv.write_from_slice(&isv_init);
let internal = unsafe { MappedF32Buffer::new(EMA_INTERNAL_SLOT_COUNT) }
.expect("alloc internal");
let mut internal_init = vec![0.0_f32; EMA_INTERNAL_SLOT_COUNT];
internal_init[EMA_INTERNAL_TRADE_DURATION] = trade_duration_ema;
internal_init[EMA_INTERNAL_AUX_P50] = aux_p50_ema;
internal_init[EMA_INTERNAL_AUX_STD] = aux_std_ema;
internal_init[EMA_INTERNAL_AUX_DIR_ACC] = aux_dir_acc_ema;
internal.write_from_slice(&internal_init);
unsafe {
launch_sp20_controllers_compute(
&stream,
&kernel,
isv.dev_ptr,
internal.dev_ptr,
)
.expect("launch sp20_controllers_compute_kernel");
}
stream.synchronize().expect("sync after sp20_controllers_compute");
isv.read_all()
}
/// Test 1 — `LOSS_CAP_INDEX` ramp boundaries.
///
/// Formula: `LOSS_CAP = -1.0 - clamp((wr - 0.50) / 0.05, 0, 1)`.
/// Expected:
/// - wr=0.50 → 1.0 (lower edge of ramp)
/// - wr=0.55 → 2.0 (upper edge of ramp)
/// - wr=0.40 → 1.0 (clamp lo: below 50% pinned at 1)
/// - wr=0.60 → 2.0 (clamp hi: above 55% pinned at 2)
#[test]
#[ignore = "requires GPU"]
fn loss_cap_ramp_boundaries() {
let tol = 1e-5_f32;
// Lower edge of ramp.
let isv = run_one_launch(0.50, 0.5, 0.1, 1.0, 0.0, 0.05, 0.5);
assert!(
(isv[LOSS_CAP_INDEX] - (-1.0)).abs() < tol,
"LOSS_CAP at wr=0.50 should be 1.0, got {}",
isv[LOSS_CAP_INDEX]
);
// Upper edge of ramp.
let isv = run_one_launch(0.55, 0.5, 0.1, 1.0, 0.0, 0.05, 0.5);
assert!(
(isv[LOSS_CAP_INDEX] - (-2.0)).abs() < tol,
"LOSS_CAP at wr=0.55 should be 2.0, got {}",
isv[LOSS_CAP_INDEX]
);
// Clamp lo: below 50% must NOT continue past 1.
let isv = run_one_launch(0.40, 0.5, 0.1, 1.0, 0.0, 0.05, 0.5);
assert!(
(isv[LOSS_CAP_INDEX] - (-1.0)).abs() < tol,
"LOSS_CAP at wr=0.40 (below 0.50) must clamp to 1.0, got {}",
isv[LOSS_CAP_INDEX]
);
// Clamp hi: above 55% must NOT continue past 2.
let isv = run_one_launch(0.60, 0.5, 0.1, 1.0, 0.0, 0.05, 0.5);
assert!(
(isv[LOSS_CAP_INDEX] - (-2.0)).abs() < tol,
"LOSS_CAP at wr=0.60 (above 0.55) must clamp to 2.0, got {}",
isv[LOSS_CAP_INDEX]
);
}
/// Test 2 — `N_STEP_INDEX` bounds.
///
/// Formula: `N_STEP = clamp(round(trade_duration_ema), 1, 30)`,
/// stored as f32. Expected:
/// - duration=0.4 → 1 (round → 0, clamp lo to 1)
/// - duration=2.5 → 2 or 3 (round, banker's or away-from-zero
/// — kernel uses `roundf` IEEE-754
/// round-to-nearest, ties to even)
/// - duration=50 → 30 (clamp hi)
#[test]
#[ignore = "requires GPU"]
fn n_step_bounds() {
// Below 1 → clamp lo to 1.
let isv = run_one_launch(0.5, 0.5, 0.1, 0.4, 0.0, 0.05, 0.5);
assert!(
(isv[N_STEP_INDEX] - 1.0).abs() < 1e-5,
"N_STEP at duration=0.4 must clamp to 1, got {}",
isv[N_STEP_INDEX]
);
// 2.5 rounds to 2 or 3 (kernel uses roundf — round-to-nearest,
// ties to even ⇒ 2.5 → 2; halfway case is the only ambiguous one).
let isv = run_one_launch(0.5, 0.5, 0.1, 2.5, 0.0, 0.05, 0.5);
let n_step = isv[N_STEP_INDEX];
assert!(
(n_step - 2.0).abs() < 1e-5 || (n_step - 3.0).abs() < 1e-5,
"N_STEP at duration=2.5 should round to 2 or 3, got {}",
n_step
);
// Above 30 → clamp hi to 30.
let isv = run_one_launch(0.5, 0.5, 0.1, 50.0, 0.0, 0.05, 0.5);
assert!(
(isv[N_STEP_INDEX] - 30.0).abs() < 1e-5,
"N_STEP at duration=50 must clamp to 30, got {}",
isv[N_STEP_INDEX]
);
// Mid-range: duration=10 → exactly 10.
let isv = run_one_launch(0.5, 0.5, 0.1, 10.0, 0.0, 0.05, 0.5);
assert!(
(isv[N_STEP_INDEX] - 10.0).abs() < 1e-5,
"N_STEP at duration=10 should be 10, got {}",
isv[N_STEP_INDEX]
);
}
/// Test 3 — `AUX_CONF_THRESHOLD_INDEX` bounds.
///
/// Formula: `AUX_CONF_THRESHOLD = clamp(aux_dir_acc_ema 0.50, 0.01, 0.20)`.
/// Expected:
/// - aux_dir_acc=0.40 → 0.01 (clamp lo: below 51% pinned at 0.01)
/// - aux_dir_acc=0.55 → 0.05 (mid-range)
/// - aux_dir_acc=0.80 → 0.20 (clamp hi: above 70% pinned at 0.20)
#[test]
#[ignore = "requires GPU"]
fn aux_conf_threshold_bounds() {
let tol = 1e-5_f32;
// Clamp lo.
let isv = run_one_launch(0.5, 0.5, 0.1, 1.0, 0.0, 0.05, 0.40);
assert!(
(isv[AUX_CONF_THRESHOLD_INDEX] - 0.01).abs() < tol,
"AUX_CONF_THRESHOLD at aux_dir_acc=0.40 must clamp to 0.01, got {}",
isv[AUX_CONF_THRESHOLD_INDEX]
);
// Mid-range.
let isv = run_one_launch(0.5, 0.5, 0.1, 1.0, 0.0, 0.05, 0.55);
assert!(
(isv[AUX_CONF_THRESHOLD_INDEX] - 0.05).abs() < tol,
"AUX_CONF_THRESHOLD at aux_dir_acc=0.55 should be 0.05, got {}",
isv[AUX_CONF_THRESHOLD_INDEX]
);
// Clamp hi.
let isv = run_one_launch(0.5, 0.5, 0.1, 1.0, 0.0, 0.05, 0.80);
assert!(
(isv[AUX_CONF_THRESHOLD_INDEX] - 0.20).abs() < tol,
"AUX_CONF_THRESHOLD at aux_dir_acc=0.80 must clamp to 0.20, got {}",
isv[AUX_CONF_THRESHOLD_INDEX]
);
}
/// Test 4 — `AUX_GATE_TEMP_INDEX` floor.
///
/// Formula: `AUX_GATE_TEMP = max(aux_conf_std_ema, 0.01)`.
/// Expected:
/// - aux_std=0.0 → 0.01 (floor)
/// - aux_std=0.05 → 0.05 (above floor, pass-through)
#[test]
#[ignore = "requires GPU"]
fn aux_gate_temp_floor() {
let tol = 1e-5_f32;
// Below floor — pinned at floor.
let isv = run_one_launch(0.5, 0.5, 0.1, 1.0, 0.0, 0.0, 0.5);
assert!(
(isv[AUX_GATE_TEMP_INDEX] - 0.01).abs() < tol,
"AUX_GATE_TEMP at aux_std=0 must equal floor 0.01, got {}",
isv[AUX_GATE_TEMP_INDEX]
);
// Above floor — pass-through.
let isv = run_one_launch(0.5, 0.5, 0.1, 1.0, 0.0, 0.05, 0.5);
assert!(
(isv[AUX_GATE_TEMP_INDEX] - 0.05).abs() < tol,
"AUX_GATE_TEMP at aux_std=0.05 should be 0.05 (above floor), got {}",
isv[AUX_GATE_TEMP_INDEX]
);
}
/// Test 5 — `TARGET_HOLD_PCT_INDEX` inverse-relation + clamps.
///
/// Formula: `TARGET_HOLD_PCT = clamp(0.8 aux_conf_p50_ema * 1.5, 0.1, 0.8)`.
/// Expected:
/// - aux_p50=0.0 → 0.8 (high target when aux is uniform)
/// - aux_p50=0.5 → 0.1 (0.8 0.75 = 0.05, clamp lo to 0.1)
/// - aux_p50=0.2 → 0.5 (mid-range: 0.8 0.30 = 0.5)
#[test]
#[ignore = "requires GPU"]
fn target_hold_pct_inverse_relation() {
let tol = 1e-5_f32;
// aux_p50=0 → 0.8 (clamp hi inactive — exact value).
let isv = run_one_launch(0.5, 0.5, 0.1, 1.0, 0.0, 0.05, 0.5);
assert!(
(isv[TARGET_HOLD_PCT_INDEX] - 0.8).abs() < tol,
"TARGET_HOLD_PCT at aux_p50=0 should be 0.8, got {}",
isv[TARGET_HOLD_PCT_INDEX]
);
// aux_p50=0.5 → 0.8 0.75 = 0.05 → clamp lo to 0.1.
let isv = run_one_launch(0.5, 0.5, 0.1, 1.0, 0.5, 0.05, 0.5);
assert!(
(isv[TARGET_HOLD_PCT_INDEX] - 0.1).abs() < tol,
"TARGET_HOLD_PCT at aux_p50=0.5 must clamp lo to 0.1, got {}",
isv[TARGET_HOLD_PCT_INDEX]
);
// Mid-range: aux_p50=0.2 → 0.8 0.30 = 0.5.
let isv = run_one_launch(0.5, 0.5, 0.1, 1.0, 0.2, 0.05, 0.5);
assert!(
(isv[TARGET_HOLD_PCT_INDEX] - 0.5).abs() < tol,
"TARGET_HOLD_PCT at aux_p50=0.2 should be 0.5, got {}",
isv[TARGET_HOLD_PCT_INDEX]
);
}
/// Test 6 — `HOLD_COST_SCALE_INDEX` two-sided multiplicative ramp.
///
/// Algorithm:
/// prev = ISV[HOLD_COST_SCALE_INDEX]
/// tgt = TARGET_HOLD_PCT (computed this step)
/// if hp > tgt + 0.05 → new = clamp(prev * 1.05, 0.01, 0.5)
/// if hp < tgt 0.05 → new = clamp(prev * 0.95, 0.01, 0.5)
/// else → new = prev
/// final clamp [0.01, 0.5] applies.
#[test]
#[ignore = "requires GPU"]
fn hold_cost_scale_two_sided_ramp() {
let tol = 1e-5_f32;
// Setup: aux_p50=0 ⇒ tgt = 0.8. Test below/at/above.
// hp=0.3, tgt=0.8 ⇒ hp < tgt - 0.05 ⇒ ramp DOWN by 5%.
let prev = 0.20_f32;
let isv = run_one_launch(0.5, 0.3, prev, 1.0, 0.0, 0.05, 0.5);
let expected_down = (prev * 0.95).clamp(0.01, 0.5);
assert!(
(isv[HOLD_COST_SCALE_INDEX] - expected_down).abs() < tol,
"HOLD_COST_SCALE: hp(0.3) < tgt(0.8) 0.05 should ramp down 5%; \
expected {expected_down}, got {}",
isv[HOLD_COST_SCALE_INDEX]
);
// Setup: aux_p50=0.4 ⇒ tgt = 0.8 0.6 = 0.2. Test above.
// hp=0.3, tgt=0.2 ⇒ hp > tgt + 0.05 = 0.25 ⇒ ramp UP by 5%.
let isv = run_one_launch(0.5, 0.3, prev, 1.0, 0.4, 0.05, 0.5);
let expected_up = (prev * 1.05).clamp(0.01, 0.5);
assert!(
(isv[HOLD_COST_SCALE_INDEX] - expected_up).abs() < tol,
"HOLD_COST_SCALE: hp(0.3) > tgt(0.2) + 0.05 should ramp up 5%; \
expected {expected_up}, got {}",
isv[HOLD_COST_SCALE_INDEX]
);
// Setup: aux_p50=0.4 ⇒ tgt = 0.2. Test in deadband [0.15, 0.25].
// hp=0.20, tgt=0.20 ⇒ |hp tgt| <= 0.05 ⇒ unchanged.
let isv = run_one_launch(0.5, 0.20, prev, 1.0, 0.4, 0.05, 0.5);
assert!(
(isv[HOLD_COST_SCALE_INDEX] - prev).abs() < tol,
"HOLD_COST_SCALE: hp(0.20) within deadband of tgt(0.20) should stay {prev}, got {}",
isv[HOLD_COST_SCALE_INDEX]
);
// Clamp hi: prev=0.49, ramp-up should hit 0.5 cap (0.49 * 1.05 = 0.5145).
let isv = run_one_launch(0.5, 0.3, 0.49, 1.0, 0.4, 0.05, 0.5);
assert!(
(isv[HOLD_COST_SCALE_INDEX] - 0.5).abs() < tol,
"HOLD_COST_SCALE: ramp up from 0.49 must clamp hi to 0.5, got {}",
isv[HOLD_COST_SCALE_INDEX]
);
// Clamp lo: prev=0.01 (the floor itself), ramp-down would go to
// 0.0095 which clamps back up to 0.01. Verifies the lo clamp
// engages on multiplicative shrink that crosses the floor.
let isv = run_one_launch(0.5, 0.3, 0.01, 1.0, 0.0, 0.05, 0.5);
assert!(
(isv[HOLD_COST_SCALE_INDEX] - 0.01).abs() < tol,
"HOLD_COST_SCALE: ramp down from 0.01 (prev * 0.95 = 0.0095) must \
clamp lo back to 0.01, got {}",
isv[HOLD_COST_SCALE_INDEX]
);
}
/// Test 7 — All 6 outputs are written in the same launch.
///
/// A single launch with non-trivial inputs MUST update all 6 ISV
/// slots. This guards against missed writes (a regression where
/// the kernel forgot one output would be hard to spot in
/// per-controller tests above since they default-initialise the
/// slots to known values).
#[test]
#[ignore = "requires GPU"]
fn all_six_outputs_written_in_one_launch() {
// Use values that cause all 6 outputs to differ from any of
// the default ISV-init (0.0) or HOLD_COST_SCALE-init (0.1).
let isv = run_one_launch(
0.55, // wr_ema → loss_cap = -2.0
0.50, // hold_pct → enters tgt-deadband for default tgt=0.8 NOT
// (0.5 < 0.75 = 0.8 0.05, so ramps DOWN 5%)
0.20, // prev cost → 0.20 * 0.95 = 0.19
10.0, // trade_dur → n_step = 10
0.20, // aux_p50 → tgt = 0.5
0.05, // aux_std → temp = 0.05
0.55, // aux_dir_ac → threshold = 0.05
);
// hold_pct=0.50 with tgt=0.5 falls in the deadband, so prev stays
// at 0.20 — verify the deadband path also writes (in-place write
// is still a write; this test ensures the kernel doesn't skip
// the write entirely on deadband).
// Re-run with hp clearly outside deadband for the differs-from-default
// assertion.
let isv2 = run_one_launch(0.55, 0.10, 0.20, 10.0, 0.20, 0.05, 0.55);
assert!((isv[LOSS_CAP_INDEX] - (-2.0)).abs() < 1e-5,
"all-outputs: LOSS_CAP must be -2.0, got {}", isv[LOSS_CAP_INDEX]);
assert!((isv[N_STEP_INDEX] - 10.0).abs() < 1e-5,
"all-outputs: N_STEP must be 10, got {}", isv[N_STEP_INDEX]);
assert!((isv[AUX_CONF_THRESHOLD_INDEX] - 0.05).abs() < 1e-5,
"all-outputs: AUX_CONF_THRESHOLD must be 0.05, got {}",
isv[AUX_CONF_THRESHOLD_INDEX]);
assert!((isv[AUX_GATE_TEMP_INDEX] - 0.05).abs() < 1e-5,
"all-outputs: AUX_GATE_TEMP must be 0.05, got {}",
isv[AUX_GATE_TEMP_INDEX]);
assert!((isv[TARGET_HOLD_PCT_INDEX] - 0.5).abs() < 1e-5,
"all-outputs: TARGET_HOLD_PCT must be 0.5, got {}",
isv[TARGET_HOLD_PCT_INDEX]);
// Second launch (hp clearly below tgt) must update HOLD_COST_SCALE
// away from prev=0.2 (downward ramp).
let expected_down = 0.20_f32 * 0.95;
assert!((isv2[HOLD_COST_SCALE_INDEX] - expected_down).abs() < 1e-5,
"all-outputs: HOLD_COST_SCALE must update on out-of-deadband hp; \
expected {expected_down}, got {}",
isv2[HOLD_COST_SCALE_INDEX]);
}
}