Two same-seed runs now produce bit-equal eval_summary.json, alpha_rl_train_summary.json,
and diag.jsonl (modulo wall-clock elapsed_s). The 5-phase falsification chain landed:
Phase 2 PER tree-rebuild: __threadfence is NOT a grid-wide barrier; multiple blocks
raced across sum-tree levels. Fix: Grid=(1) Block=(1024) + __syncthreads
in rl_per_tree_rebuild.cu.
Phase 2.3 cuBLAS GEMM_DFALT + TF32 default-math allowed split-K non-deterministic
accumulation at 3 sites. New crates/ml-alpha/src/cublas_determinism.rs
applies CUBLAS_PEDANTIC_MATH via FOXHUNT_DETERMINISTIC env toggle
(0=TF32 prod, 1=PEDANTIC dev default, 2=DEFAULT_MATH control).
Phase 2.6 Two bugs surfaced sequentially in the backward kernel chain:
(1) rl_iqn_tau_cos_features had a multi-block r/w race on prng_state[batch]
— all N_TAU=32 blocks read seed; only tau_idx==0 wrote back; no
inter-block barrier. Fix: split into READ-ONLY rl_iqn_tau_cos_features
+ new sibling rl_iqn_advance_prng_state launched on same stream
(kernel-launch ordering = grid-wide barrier).
(2) OutcomeHead::new called near_zero_xavier without scoped_init_seed,
falling back to time+thread-id RNG. Stayed dormant until first done
event activated non-sentinel labels and divergent weights flowed via
grad_h_t_outcome into encoder gradient. Fix: add seed param + install
scoped_init_seed(dqn_seed.wrapping_add(0x0CE0)) guard.
Validation (./scripts/determinism-check.sh --quick, RTX 3050, b=128, 200+50 steps):
- All 200 rows of checksums.* leaves match (rel-tol 1e-5, abs-tol 1e-7)
- eval_summary.json, alpha_rl_train_summary.json byte-equal between runs
- diag.jsonl byte-equal modulo elapsed_s
- Eval pnl identical run-A vs run-B at seed 42
Pre-fix baseline (Phase 2.5 measurement): same-seed eval pnl spread $450k
($187k vs -$261k). Post-fix: $0 spread.
Speed cost: ~1.5ms/step amortised; ~10-15% slower than TF32 production
(PEDANTIC tax — acceptable in dev, toggle to FOXHUNT_DETERMINISTIC=0 for prod).
Mapped-pinned discipline: all 11 NEW memcpy_dtoh sites in diagnostic dump methods
+ per-step checksum readback use a new pub(crate) helper
read_slice_d_into<T: Copy>(stream, src, dst) — MappedRecordBuffer + raw
memcpy_dtod_async + raw_stream_sync + volatile read. Generic over T (f32, f64,
i32, u32, u8). Satisfies feedback_no_htod_htoh_only_mapped_pinned + hook guard.
Bundled Tier 1.5 fast-dev-cycle infrastructure (spec
docs/superpowers/specs/2026-06-02-fast-dev-cycle.md):
- scripts/local-mid-smoke.sh b=128, 2000+500, ~10min on RTX 3050
- scripts/determinism-check.sh runs mid-smoke twice, diffs checksums
- scripts/tier1_5_verdict.py behavioral kill verdict
- AdamW checkpoint save/load (crates/ml-alpha/src/trainer/optim.rs)
- IntegratedTrainer checkpoint save/load (resume from checkpoint)
- 15 Phase 1 checksum leaves in build_diag_value
- Env-gated dump methods (FOXHUNT_DETERMINISM_DEBUG_PER/MAMBA2/RL/BACKWARD)
for future divergence-chasing — never run in production
Documentation:
- docs/superpowers/specs/2026-06-02-determinism-foundation.md
- docs/superpowers/specs/2026-06-02-fast-dev-cycle.md
- docs/superpowers/plans/2026-06-02-determinism-foundation-implementation.md
- docs/superpowers/notes/2026-06-02-determinism-phase{1,2,2.2,2.5,2.6}-*.md
- Adjacent specs/plans/notes from the analytical chain that surfaced determinism
as the load-bearing blocker (eval-summary, eval-boundary, regime-observer,
multi-head policy, regime-invariance, Phase 3 IQN-complement post-mortem)
Unlocks: every controller / architecture / reward-shaping A/B from this commit
onward attributes outcome differences to the change, not random-init kernel-race
drift cascading through training x eval LOB-sim trajectories. The eval-collapse
investigation (pearl_reward_signal_anti_aligned_with_pnl, multi-head spec,
regime-invariance spec) is now testable with trustworthy verdicts.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
10 KiB
Determinism Phase 1 — Diagnosis
Date: 2026-06-02
Branch: ml-alpha-adaptive-controller-floors
Spec: docs/superpowers/specs/2026-06-02-determinism-foundation.md §1
Status: Phase 1 SHIPPED — diagnosis complete, fix is Phase 2 (separate dispatch).
TL;DR
scripts/determinism-check.sh --quick (200 train + 50 eval at b=128, seed=42) on
ml-alpha-adaptive-controller-floors produces:
FIRST_DIVERGENCE: step=2 leaf=checksums.replay_sample_indices
run_A=709847.0 run_B=2851599.0 delta=2.14175e+06
Culprit: PER (Prioritized Experience Replay) sampling. Spec §2.F is the fix.
Steps 0-1 are bit-equivalent across runs. Divergence appears at step 2 the moment the replay buffer transitions from "trivially deterministic" (small, all entries equally probable) into "PER-sampled with priority weights".
Phase 1 deliverables
-
Kernel —
crates/ml-alpha/cuda/rl_deterministic_checksum.cu- Two entry points:
rl_deterministic_checksum_f32+rl_deterministic_checksum_i32 - Both are
(1,1,1)/(1,1,1)launches — a single thread sumsdata[i]²into a double-precision accumulator. NO atomics, NO multi-block reduce, NO non-deterministic patterns by construction. Perfeedback_no_atomicadd. - Cost per launch: ~5-20 μs launch overhead + sequential f64 accumulation
(~1ns per element on RTX 3050). Largest tensor we checksum is the
Q-head weight
ss_q_grad_w_dat ~24k floats — 24 μs sequential. - Registered in
crates/ml-alpha/build.rs(AOT cubin compile perfeedback_no_nvrtc).
- Two entry points:
-
Diag emission —
crates/ml-alpha/src/trainer/integrated.rs- New method
IntegratedTrainer::compute_diag_checksums(&mut self) → Result<[f64; 15]> - Allocates a 28-f64 scratch buffer (
checksum_scratch_d) once at trainer construction; 15 final-leaf slots + 12 Adam staging slots (the optimizer's m and v live in six separate sub-buffers for the DQN/π/V w+b parameter groups; host-folded into adam_m_sum / adam_v_sum after the dtoh). build_diag_valueis now&mut self; it callscompute_diag_checksumsup-front and inserts the 15 leaves under a new top-levelchecksumsJSON object.- 15 leaves emitted per spec §1.2:
encoder_output,q_logits,pi_logits,v_value,replay_sample_indices,rewards_after_shape,advantages,q_grad,pi_grad,v_grad,encoder_grad,adam_m_sum,adam_v_sum,isv_state,popart_sigma_state. tests/eval_diag_emission.rsEXPECTED_LEAVESbumped 679 → 694.- Call-site fix in
crates/ml-alpha/examples/alpha_rl_train.rs: the train-looplet isv = trainer.isv_host_slice()borrow conflicted with the new&mut selfonbuild_diag_valuebecause the slice was referenced past the call in aneprintln!. Fix is local — pre-extract the 4 ISV values intof32locals before the diag call. Eval loop was unaffected (NLL drops the borrow before the diag call there).
- New method
-
Diagnostic —
scripts/determinism-check.shrewritten- Iterates over EVERY row of
diag.jsonl(was: last 5 rows) - Compares only the
checksums.*subtree leaf-by-leaf (other diag fields are derived quantities; localising to component checksums gives a cleaner signal) - Reports first
(step, leaf)divergence at rel-tol=1e-5 + abs-tol=1e-7 - Also lists ALL leaves diverging at the first divergent step (multiple components frequently co-diverge — the script ranks them by Δ magnitude so the top entry is the most-likely root cause)
- Maps each known leaf to its spec §2 fix recommendation
- Exit 0 = deterministic / 1 = drift detected / 2 = inputs missing
- Iterates over EVERY row of
Diagnostic findings — step-by-step
Output of ./scripts/determinism-check.sh --quick:
| Step | Diverging leaves | Top Δ |
|---|---|---|
| 0 | 0 (bit-equivalent) | — |
| 1 | 0 (bit-equivalent) | — |
| 2 | 5 simultaneously: replay_sample_indices, q_grad, adam_m_sum, q_logits, adam_v_sum |
2.14e6 on replay_sample_indices |
| 3 | 11 (drift cascades through forward + grads + ISV) | — |
| 4-199 | 10-15 leaves drift on every step | — |
Total divergent rows: 198 / 200.
Interpretation
The cascade ordering is structurally clean:
-
Step 0-1: PER buffer has not accumulated enough transitions for priority-weighted sampling to actually choose different indices (or the priorities are still degenerate). Both runs produce identical indices, identical sampled
h_t/h_tp1, identical Q forward, identical gradients, identical Adam state. Bit-equivalent. -
Step 2 — divergence enters via
replay_sample_indices. The two runs choose different replay transitions to sample. From this moment the Q gradient (which depends on the sampled batch) diverges, which immediately diverges Q-head Adam m+v, which on the same step divergesq_logits(via the immediately-following Q forward in the next step's prefill). The 5-simultaneous divergence at step 2 is consistent with a single root cause (PER sampling) propagating to all downstream buffers in the same training step. -
Step 3+: now divergent gradients have been Adam-applied to parameters → encoder forward diverges (
encoder_output), π and V forward diverge, advantages diverge, controllers' EMA inputs diverge, ISV state diverges. Cascade complete.
The fact that replay_sample_indices is the FIRST and LARGEST-magnitude
divergence at step 2, while every other field is bit-equivalent up
through step 1, is decisive: the bug is in the PER sampling kernel chain.
Candidate root causes (Phase 2 investigation scope)
Spec §2.F lists the entry points to audit:
-
rl_per_samplekernel — stratified proportional sampling via sum-tree walk + gather. The walk uses a device-side xorshift32 PRNG (gpu_replay.sample_prng_d). Audit whether:- The PRNG state is seeded deterministically and not reset between
runs (must use
cli.seed.wrapping_add(...)per the existing CPU-side audit in items 1-5 impl log). - The kernel's launch configuration (Grid=B) reads state in a deterministic order (each block owns one batch slot → fixed).
- The sum-tree walk converges deterministically — IEEE-754 sum-tree comparisons can produce different paths if the tree values were accumulated in a non-deterministic order (PER push_flush uses prefix-sum allocation, which is non-deterministic on parallel warps if the input order varies).
- The PRNG state is seeded deterministically and not reset between
runs (must use
-
rl_per_push_flushandrl_per_tree_rebuild— coordinated coalesced replay writes + bottom-up parallel sum-tree rebuild. Both "have no atomics" per their build.rs comments, but the rebuild's bottom-up parallelism may produce different floating-point sums if the tree leaf order differs across runs. Audit: does the tree rebuild deterministically reduce children before parents? -
rl_per_update_priority— writes|TD|^αto tree leaves + block- wide max reduction. The block-wide max should be deterministic, but any precision-sensitive aggregation could be the culprit. Less likely. -
Priority update sequencing —
pearl_rl_sample_tau_xorshift32notes the PRNG seeding pattern. Verify the seed reachessample_prng_dAND is not perturbed by any unrelated state at construction time.
Recommendation for Phase 2
Apply spec §2.F (PER sampling determinism audit). Concrete steps:
- Add per-batch dumps of
sample_prng_d,priority_tree_d, andsample_indices_dat step 0, 1, 2 of two same-seed runs. Compare PRNG state at step 1's exit / step 2's entry — if PRNG already diverges → fix the seeding; if PRNG matches but indices differ → the priority-tree walk is the culprit. - If priority tree is suspect, audit
rl_per_tree_rebuildfor any parallel reduction whose addition order depends on warp scheduling. The "no atomics" claim doesn't preclude order-of-addition variance in a parallel scan. - Apply the deterministic-by-construction fix (single-block reduction for small trees, or fixed-warp ordering for large trees).
- Re-run
scripts/determinism-check.sh --quickafter the fix. Success = "DETERMINISTIC: allchecksums.*leaves match across all 200 rows".
Once PER is fixed, the next divergence (if any) localises automatically. Phase 1 infrastructure is reusable — Phase 2 just runs the same script.
Files touched (Phase 1, no commits per dispatch instruction)
| Path | Change |
|---|---|
crates/ml-alpha/cuda/rl_deterministic_checksum.cu (NEW) |
Sum-of-squares kernel, two entry points (f32 + i32) |
crates/ml-alpha/build.rs |
Registered the new kernel in KERNELS |
crates/ml-alpha/src/trainer/optim.rs |
Added immutable m() / v() accessors on AdamW |
crates/ml-alpha/src/trainer/integrated.rs |
New cubin const + struct fields, module loading in new(), compute_diag_checksums() method, build_diag_value switched to &mut self + emits checksums JSON object |
crates/ml-alpha/examples/alpha_rl_train.rs |
Pre-extracted ISV values into locals to release the isv_host_slice() borrow before calling build_diag_value (now &mut self) |
crates/ml-alpha/tests/eval_diag_emission.rs |
EXPECTED_LEAVES 679 → 694 |
scripts/determinism-check.sh |
Rewritten to compare all rows × checksums.* leaves; reports FIRST_DIVERGENCE + spec §2 fix recommendation |
docs/superpowers/notes/2026-06-02-determinism-phase1-diagnosis.md (NEW, this file) |
Diagnosis log |
Discipline checklist
- No new files in repo root
- No new docs outside
docs/superpowers/notes/ - CUDA kernel registered in
build.rsperfeedback_no_nvrtc - Kernel uses NO atomics per
feedback_no_atomicadd - Result read from CPU only; computation entirely on device per
feedback_cpu_is_read_only - All 15 checksums end-to-end wired — no stubs per
feedback_no_stubs - Local RTX 3050 smoke validation only — NO cluster submission
- Read-before-Edit on all touched existing files
- No fix applied — diagnosis only, per dispatch instruction (Phase 2 is separate)
- No commit — left uncommitted for human review
Reproduction
# Build
SQLX_OFFLINE=true cargo build --release --example alpha_rl_train -p ml-alpha
# Run
./scripts/determinism-check.sh --quick
# Expected output (current branch ml-alpha-adaptive-controller-floors):
# FIRST_DIVERGENCE: step=2 leaf=checksums.replay_sample_indices
# run_A=709847.0 run_B=2851599.0 delta=2.14175e+06