Skip to content

MT=91 Law-61 physics fix (CPU + GPU) + GPU survival biasing + ENDF Q-values - #10

Merged
sorcerer86pt merged 19 commits into
mainfrom
drop-orchestration-keep-physics
May 28, 2026
Merged

sorcerer86pt merged 19 commits into
mainfrom
drop-orchestration-keep-physics

Conversation

@sorcerer86pt

Copy link
Copy Markdown
Owner

Summary

  • MT=91 (continuum inelastic) physics fix: load Law 61 (KalbachMann) mu coupling from HDF5 and sample correlated μ in the MT=91 branch. Drops a double partition in the outgoing-energy sampler. Mirrored onto the CUDA path via 4 new transport.cu slots (N_PARAMS 191 → 195). Adds U-235 unit test + cuda_survival_biasing_unbiased_godiva parity test.
  • GPU survival biasing: physics only (slots 192-194 — P_SURVIVAL_BIAS_ENABLED, P_W_MIN, P_W_SURVIVE). Defaults reproduce the analog path bit-for-bit; off-by-default. Exposed to Python via PySettings::survival_biasing and the sweep harness. No orchestration plumbing — variance only, k_eff stays unbiased.
  • ENDF Q-values for (n,2n) / (n,3n) in gr_multi_event (real ENDF values, not hardcoded).
  • Interpolated discrete-level σ for inelastic sampling (fixes piecewise-constant artifact between level grid points).
  • Diagnostics: u235_inelastic_audit binary (per-MT σ SVD vs Table dump), per-MT inelastic split tally + r= rank arg for metal_stats_diag, OpenMC reference tally / U-235 ν̄ inspectors.
  • Docs: engine-vs-OpenMC Godiva bias investigation notes; CLAUDE.md/STATUS.md rewrites reflecting rollback to subprocess-per-case driver (the in-process force_rebuild() watchdog was corrupting subsequent cases — NaN k_eff after a single timeout).
  • GPU runtime verification on RTX 3080 (outputs/icsbep_full_gpu.{csv,log} — exp run on 3080, commit 4c621e1): 3 cases completed end-to-end. Slower than A1000 baseline but functionally green — preferring slower than none.

Test plan

  • cargo test --lib — baseline 438 + new MT=91 Law-61 test
  • cargo test --lib --features cuda — baseline 443 + new cuda_survival_biasing_unbiased_godiva parity test
  • GPU end-to-end on RTX 3080 (3 ICSBEP cases; see outputs/icsbep_full_gpu.{csv,log})
  • Re-run full 375-case ICSBEP sweep on a beefier GPU once available (deferred per CLAUDE.md "Open / deferred")

The event-based multi-emission kernel had been using
`Q_mult = -0.1 × E_inc` (n,2n) / `-0.2 × E_inc` (n,3n) — a
hand-wave that's ~correct for actinide thresholds but wildly wrong
for light nuclides. Be-9's real (n,2n) Q is −1.665 MeV, fixed,
versus the kernel's ~−100 keV at typical fast-spectrum incidents:
the continuing primary's outgoing kinematics carried ~15× too much
energy, hardening the spectrum returned from any Be reflector.
This is the +800–1280 pcm bias signal on heu-comp-inter-003
case-2/3, heu-met-fast-019 case-1/2 in the 5090 sweep
(`results/icebsp_run_5090_full.txt`).

Plumbing:

- `NuclideKernels` gains `q_n2n: f64` / `q_n3n: f64`. Populated from
  `reader.reaction_q_value({16,17})` at every load site (HDF5
  policy-rank loader, table loader, stoch-T loader, the read-on-
  error fallback) and at the empty-placeholder constructor. Cache
  binary format bumped 3 → 4 (forces L2 rebuild) and adds two `f64`
  fields between `q_n_np` and `urr_tables`.
- `GpuMaterialData` gains `q_n2n_table` / `q_n3n_table` device
  buffers, populated from per-nuclide `q_n2n`/`q_n3n` arrays passed
  to a two-argument-extended `upload_material_data`. All 9 call
  sites (Python binding, tests, 7 binaries) build the arrays from
  the loaded `NuclideKernels` (or `provider.nuclides` where that's
  the available handle) and pass them through.
- `transport.cu` adds two new params slots — `P_Q_N2N_TABLE = 186`
  and `P_Q_N3N_TABLE = 187` — and `N_PARAMS` bumps 186 → 188.
  `gpu_transport.rs:N_PARAMS` mirrors. The single-source-of-truth
  invariant in CLAUDE.md still holds.
- `gr_multi_event` reads `__ldg(&PTR_D(p, P_Q_{N2N,N3N}_TABLE)[hit_nuc])`
  for the continuing primary's CM kinematics. The hand-wave
  fallback survives for the `Q_mult >= 0.0` case (nuclide doesn't
  carry the MT, or malformed dataset) so the kernel still produces
  a physically-ish secondary instead of leaking ν̄ at E_inc.

The emitted-secondary loop above still uses the analytic
`temp = E/10, e_sec = -temp × log(x1 × x2)` Maxwell. A Q-aware
`temp` was tried (`max(50keV, (E - |Q|)/(2(n_extra+1)))`) but on
Godiva moved k 80 pcm in the wrong direction at marginal statistical
significance — the right temperature for each (n,2n) channel
depends on the real ENDF outgoing-energy spectrum, which the GPU
doesn't sample today. Left a `TODO` pointing at the diagnostic
OpenMC tally diff that will confirm which channels need the full
table (Fix #2).

Smoke validation (RTX A1000, post-Fix #1, 50k × 100b × 1 seed):

- HMF-001 Godiva case-1: k = 1.00112 ± 0.00038, Δ = +112 pcm
  (pre-fix on 5090: Δ = +234 pcm). 3+σ drop, real signal. U-238
  (n,2n) contributes more on fast Godiva than I expected — the
  fix value is mostly in the long fission-spectrum tail past the
  U-238 threshold (~6 MeV).
- HCI-003 case-2 (Be reflector): k = 1.01231 ± 0.00121,
  Δ = +1231 pcm (pre-fix on 5090: Δ = +1279 pcm). 50 pcm drop
  within noise (combined σ ≈ 150 pcm). Be(n,2n)'s SHAPE bias
  isn't captured by the Q fix alone — secondaries still use the
  E/10 Maxwell, which on a 2 MeV incident gives 200 keV
  secondaries when the real (n,2n) evaporation distribution has
  T ≈ 50–80 keV. Fix #2 (real per-nuclide outgoing-energy tables)
  will close this; queued.

Vestigial bin rename: `gpu_recursive_keff` → `gpu_godiva_keff`.
The "recursive" referred to the now-deleted
`transport_recursive_persistent` kernel; the binary in fact runs
the event-based pipeline on a Godiva geometry, so the new name
matches the body. CLAUDE.md / README.md updated.

`cargo check --features cuda --all-targets`: clean.
NuclideKernels::discrete_level_xs (SVD provider) was using
ReactionKernel::lookup, which returns σ at the nearest grid point ≤ E
with no log-log interpolation. The transport hot path's MT=4 amplitude
in MicroXs is built with reconstruct_interp (log-log between brackets),
so the per-level σ ratios fed to sample_inelastic_level were drawn from
a different lookup mode than the rate they were normalised against.

On U-235's union grid (82k points), this skews the MT=91 / MT=51..90
branching: MT=91 has a rising ramp where grid-point σ < interp σ, while
many discrete MT=51..89 cells have falling ramps where grid-point σ >
interp σ. Net effect on Godiva: engine under-fires MT=91 by ~1.9
absolute percentage points (42.6 % vs OpenMC's 44.5 %), and that
under-firing leaves the population spectrum slightly harder than OpenMC,
biasing k_eff upward by ~180 pcm.

Fix computes (idx, log_frac) once from any level kernel's grid (all
discrete levels share the same union grid Arc) and calls
reconstruct_interp on each level's kernel. Matches the SvdXsProvider's
MicroXs build pattern exactly. The Table provider's discrete_level_xs
already used interpolated lookups via StochTempTable, so this fix is
SVD-only.

Empirical (Godiva b=80 i=20 p=20000 s=42 r=15):
  MT=91 / MT=4 branching: 42.6 % → 43.5 % (target 44.5 %)
  k_eff (1 seed):         1.00178 → 1.00136 (−42 pcm)

438 / 438 lib tests still green (debug).
Two coupled bugs in MT=91 (continuum inelastic) outgoing-energy
sampling were biasing Godiva-class fast-metal k_eff +200-300 pcm
high vs OpenMC.

(1) HDF5 loader missed Law 61 / "correlated" layout
============================================================
read_reaction_edist_from_file recognised two distribution_0
layouts: nested (fission, MT=18) and flat-uncorrelated (energy +
distribution datasets). U-235/U-238/U-234 MT=91 in ENDF/B-VIII.1
use a third layout — type="correlated" with energy, energy_out,
and mu datasets (the energy_out rows 0..3 hold E_out/PDF/CDF, rows
3..5 are KalbachMann mu-coupling). The loader silently returned
None for every U-isotope MT=91, dropping the sampler into the
analytic evaporation fallback. Added a third branch reading
distribution_0/energy_out as the marginal f(E -> E') with the same
rows 0..3 convention; mu-coupling is dropped (continuum is
isotropised in CM by sample_inelastic_level).

(2) Double-partition in q_eff for the continuum branch
============================================================
sample_inelastic_level synthesised q_eff = E_out - E_cm so
inelastic_scatter would land at e_cm_out = E_out, then applied
the two-body partition factor A/(A+1) to derive e_neutron_cm.
That partition is correct for discrete levels (MT=51..90), where
e_cm_out is the system kinetic energy that the neutron + recoil
share. For MT=91 the sampled E_out from a center_of_mass=true
product IS already the neutron's CM kinetic energy directly
(OpenMC's convention for Law 4 / Law 61 products) — the recoil
share is implicit in the tabulation. Applying A/(A+1) again is
the second copy of the partition.

Fix: invert the partition into the synthesised q_eff so that
inelastic_scatter's existing two-body math produces
e_neutron_cm = E_out exactly:
  e_cm_out_desired = E_out * (A+1)/A
  q_eff = e_cm_out_desired - e_cm = E_out * (A+1)/A - E*A/(A+1)
This keeps the discrete-level path bit-identical and only the
MT=91 continuum branch changes.

Verification (b=100 i=20 p=20000 3-seed mean on a 5090-class box)
============================================================
                Engine before    Engine after    OpenMC ref
HMF-001-c1     1.00325 +325     0.99993 -7      0.99916  (Δ +77 pcm)
HMF-027        1.00685 +685     1.00273 +273    1.00322  (Δ -49 pcm)
HCI-003-c2     1.01231          1.01251         1.01242  (within MC noise)
HMF-019-c1     1.00827          1.00643 -184    1.00827  (Δ -184; OpenMC ref is single-seed, σ~173 pcm → within combined 1σ)

MT=91 / MT=4 branching on Godiva: 42.6% -> 44.1% (target 44.5%).
cargo test --lib: 438 / 438 green.

The 5090 ICSBEP sweep biases on fast metals (+200-1300 pcm on
~20 of 115 cases) localise to this pair of bugs. The graphite-
reflected HMF-019 case sat at -1 pcm before by coincidence — the
old code's evaporation fallback for U-235 MT=91 happened to
match OpenMC's k on that geometry. The new physically-grounded
path lands within combined MC noise of OpenMC, not by
coincidence.

Open follow-ups
============================================================
- GPU mirror: gpu_per_nuclide.rs uploads inelastic_continuum_edist
  via upload_energy_distribution; the SAME q_eff partition fix
  must land in transport_recursive.cu's MT=91 branch. Out of
  scope for this CPU-only commit.
- Kalbach-Mann mu coupling: we drop rows 3..5 of energy_out and
  isotropise in CM. Pulling in the explicit mu-from-E' coupling
  would tighten the angular distribution further; expected
  impact is <50 pcm based on the Godiva residual.
Wires a continuum-vs-discrete inelastic split through the CPU
BatchResult so metal_stats_diag can break out MT=91 from MT=51..90
in its per-reaction tally. The CPU dispatcher reads the new
`n_inelastic_continuum` field; the CUDA branch sets it to 0 (GPU
mirror pending). Adds a `r=N` CLI arg to metal_stats_diag for
SVD rank override (was hardcoded 15).

This is the diagnostic substrate the Godiva +200 pcm bias
investigation needed to localise the per-MT rate imbalance —
fixed in 77a5b28 + d6d2c6d.
New diagnostic binary that loads U-235 from one HDF5 file twice —
once via load_nuclide_with_policy (rank-k SVD), once via
load_nuclide_table (pointwise) — and dumps σ_MT(E) for every
MT ∈ {4, 51..91} on a log-spaced [0.1, 10] MeV grid plus
threshold-bracket points. Both lookups go through the hot-path
log-log interpolation (`ReactionKernel::reconstruct_interp`,
`StochTempTable::lookup_at_idx`) so the comparison reflects what
the transport loop actually sees, not the non-interpolating
`ReactionKernel::lookup` that the prior convenience method used.

Supports `--mt91-table` to force the MT=91 continuum to a
pointwise `ReactionKernel::Table` via `RankPolicy::with_table(91)`
(now honoured by the discrete-level loader at xs_provider.rs:1505,
see comment there).

CSV columns: energy_eV, mt, threshold_eV, svd_xs_b, table_xs_b,
abs_diff_b, rel_diff. Console summary prints worst |rel_diff| per
MT and Σ(MT=51..91) vs MT=4 sanity check.

This is what ruled out the earlier "SVD MT=91 reconstruction
bias" hypothesis and re-pointed the investigation at level
selection / outgoing-energy frame handling, which the d6d2c6d
fix targets.
Adds + extends the OpenMC reference + diagnostic scripts the
Godiva bias investigation used:

- openmc_godiva_tallies.py: 3-seed × 100b × 20k OpenMC run on the
  Godiva geometry with per-nuclide MT=4 and MT=91 isolated
  tallies. Output at outputs/openmc_godiva_tallies.json drives
  the engine-vs-OpenMC per-MT rate comparison in metal_stats_diag.
  Reads OPENMC_GODIVA_DATA env var; defaults to ENDF/B-VIII.1.

- openmc_scene_runner.py: per-nuclide per-MT tallies (extends
  the prior cell-level fission/absorption/scatter set with
  (n,2n)/(n,3n)/MT=91/per-nuclide breakdown). Used to generate
  outputs/openmc_diag/*_omc.json for arbitrary ICSBEP scenes.

- openmc_sweep_diag.sh: runs openmc_scene_runner.py over a list
  of representative cases (Godiva, HCI-003, HMF-019, HMF-027,
  HST-004) so the engine-vs-OpenMC tally comparison generalises
  beyond Godiva.

- inspect_u235_nu.py / inspect_u235_watt.py: dump U-235's ν̄(E)
  interpolation codes + fission distribution structure. Used to
  rule out ν̄(E) and Watt sampling as sources of the +200 pcm
  Godiva residual.
Records the full investigation cascade that led to the +200 pcm
Godiva bias fixes in 77a5b28 + d6d2c6d:

- OpenMC head-to-head on 5 representative ICSBEP cases that
  bisected the bias to fast-only spectra (Godiva, HMF-027) vs
  moderator-reflected (HMF-019, HCI-003).
- Hypothesis cascade that ruled out: SVD rank, Watt sampling,
  ν̄(E) interpolation, source convergence, (n,2n) Q-value, URR
  bin interp.
- Task #20 audit (rust_prototype/src/bin/u235_inelastic_audit.rs)
  showing SVD per-channel σ_MT(E) reconstruction is machine-
  precision correct under matched log-log interpolation —
  superseding the earlier "SVD MT=91 bias" hypothesis the doc
  body originally claimed.
- Hypothesis re-check (2026-05-25) re-pointing the investigation
  at the inelastic-level selection branching (engine MT=91/MT=4
  = 42.6 % vs OpenMC 44.5 %), which then localised to the two
  bugs fixed in 77a5b28 (non-interp discrete σ feeding level
  selection) and d6d2c6d (Law 61 loader + MT=91 double-partition
  in `inelastic_scatter`).

Preserves the chain so future regressions on Godiva-class fast
metal don't re-derive the same conclusions from scratch.
Extends `read_reaction_edist_from_file` to pull the correlated mu
distribution alongside the marginal f(E_in -> E_out) for layout (c)
(OpenMC `CorrelatedAngleEnergy`). Previously rows 3..5 of `energy_out`
were dropped and MT=91 scattering was isotropised in CM, contributing
~50 pcm residual on Godiva vs OpenMC.

Real HDF5 layout (re-verified via inspect_mt91 binary on U-235 VIII.1)
============================================================
`distribution_0/energy_out` is shape (5, N) with rows:
  0: E_out (eV)
  1: PDF
  2: CDF
  3: interpolation_per_E_out_bin (1=histogram, 2=lin-lin)
  4: cumulative offsets into the sibling `mu` dataset
`distribution_0/mu` is shape (3, M):
  0: mu cosine
  1: PDF
  2: CDF
mu rows are flat-concatenated over (E_in, E_out) bins; bin slicing
is keyed by `energy_out` row-4 offsets. U-235 MT=91 in VIII.1 carries
real Law 61 data (mu mean varies from slightly backward at high E_out
to slightly forward at low E_out for compound-system breakup).

Loader changes (`hdf5_reader.rs`)
============================================================
- New struct `CorrelatedAngleEnergy { mu_dists: Vec<Vec<TabularMuDist>> }`
  with `sample_mu_at(e_in_bin, e_out_bin, e_out_frac, rng)` for
  stochastic-bin mu sampling between bracketing E_out bins (matches
  OpenMC's `CorrelatedAngleEnergy::sample`).
- New field `EnergyDistribution::mu_dist: Option<CorrelatedAngleEnergy>`.
- New method `EnergyDistribution::sample_with_mu(E_in, rng) -> (E_out,
  Option<mu_cm>)` that returns the correlated mu when available and
  `None` otherwise (Law 4 / Watt / Maxwell / Evaporation fall through).
- New helper `TabularEnergyDist::sample_with_bin` that returns both
  the sampled E_out and the chosen bin index (needed to look up the
  matching mu table).
- `read_reaction_edist_from_file` layout (c) branch now reads
  `energy_out` row 4 (mu offsets) and the sibling `mu` dataset,
  building one `TabularMuDist` per (E_in_bin, E_out_bin). Returns
  `mu_dist: None` when the table is uniformly isotropic to keep
  downstream sampling on the fast path.

Cache format bump
============================================================
`FORMAT_VERSION` 4 -> 5. Existing on-disk caches will rebuild on
next run; the new format adds an `Option<CorrelatedAngleEnergy>` slot
after the closed-form law field.

Diagnostic binary
============================================================
`inspect_mt91 <hdf5> <nuclide> <mt>` dumps the per-row min/mean/max
of `energy_out` and `mu` plus a sample slice at ~5 MeV E_in. Used
to confirm the HDF5 layout matches OpenMC's CorrelatedAngleEnergy.

Tests
============================================================
cargo test --lib: 438 / 438. No behavioural change yet — the new
mu_dist field is populated but not consumed; the collision pathway
follows in the next commit.

Refs: docs/engine-vs-openmc-bias-investigation.md (≤50 pcm residual
on Godiva traced to MT=91 isotropic mu).
Wires the Law 61 mu coupling loaded in the previous commit through
to neutron emission. `sample_inelastic_level` now also returns an
optional CM-frame cosine override, and a new `inelastic_scatter_with_mu`
in `scatter.rs` accepts it. When `continuum_edist` carries a
`CorrelatedAngleEnergy`, the sampled mu is correlated with E_out via
the same stochastic bin choice (matching OpenMC's
`CorrelatedAngleEnergy::sample`).

Precedence in inelastic_scatter_with_mu's CM-mu pick:
  1. mu_override from Law 61 KalbachMann (MT=91).
  2. ENDF tabulated UncorrelatedAngleEnergy for the discrete level.
  3. Isotropic CM fallback.

`inelastic_scatter(..)` keeps its original signature and forwards
to `inelastic_scatter_with_mu(.., None, ..)` so external callers and
the discrete-level path are bit-identical.

cargo test --lib: 438 / 438.
Mirror CPU commit 77a5b28 into the CUDA backend. The persistent and
event-based GPU transport kernels build per-level σ inside the legacy
inelastic level walk via `svd_reconstruct`, which evaluates only at the
nearest grid point ≤ E. The transport hot path elsewhere
(`eval_nuclide_macro_xs`, the synthesised MT=4 CDF) uses
`svd_reconstruct_interp` for log-log interpolation between grid
brackets, so the per-level σ ratios driving level selection were drawn
from a different lookup mode than the σ amplitudes they're normalised
against.

On U-235's 82k-point union grid the mismatch biases the MT=91 /
MT=51..90 branching by ~1.9 absolute percentage points and the engine
spectrum slightly harder, mapping to ~180 pcm of k_eff bias on Godiva
(CPU number reproduced after the same fix on host).

Fix: compute `log_frac` once from the union grid in both kernels'
level-walk blocks (transport.cu fallback inside the persistent inelastic
branch, transport_event_based.cu's `gr_inelastic_event`) and call
`svd_reconstruct_interp(..., e_idx, n_e, rank, log_frac)` instead of
`svd_reconstruct(..., e_idx, rank)`. The CDF synthesis path was already
interpolating; only the legacy two-pass walk needed the same treatment.

Verification (RTX A1000 laptop, ENDF/B-VIII.1, Godiva b=80 i=20 p=20000
s=42 r=15) — combined with the d6d2c6d mirror in the next commit:
  CPU k_eff:        0.99869 ± 110 pcm
  GPU k_eff:        0.99806 ± 114 pcm
  Δ k_eff:          −63 pcm   (well within combined seed noise)
  CPU MT=91/inel:   44.1 %    (matches OpenMC reference)
  GPU inel/source:  0.5363    (CPU 0.5385, Δ −0.4 %)

cargo test --lib --features cuda: 449 / 449 green
cargo test --lib:                 436 / 436 green
Two new tests in `hdf5_reader::mt91_mu_coupling_tests`:

1. `u235_mt91_mu_coupling_is_loaded_and_normalised` (disk-gated)
   Loads U-235 MT=91 from `data/endfb-viii.{1,0}-hdf5/neutron/U235.h5`
   (or `$ICSBEP_DATA_DIR/U235.h5`) and verifies:
   - `mu_dist` is `Some(_)` — confirms Law 61 coupling parses.
   - `mu_dists.len() == energies.len()` — per-E_in row count matches.
   - 200 k `sample_with_mu(5 MeV)` draws yield Some(mu) every time,
     stay in [-1, 1], have |<mu>| < 0.30 (compound system), and
     var(mu) > 0.05 (rules out a delta-at-0 bug).
   - Empirical mu histogram (20 bins over [-1, 1]) integrates to
     1 ± 2 %, with ≥ 15 of 20 bins populated.
   - <E_out> < E_cm of relative motion.

   Skipped (with `eprintln!`) when the data dir isn't on disk —
   matches the convention used in `tests/cache_roundtrip.rs`.

2. `sample_with_mu_returns_none_when_no_coupling` (self-contained)
   Synthesises an `EnergyDistribution` with `mu_dist: None` and
   confirms `sample_with_mu` returns `(E_out, None)` so the
   collision branch falls through to isotropic emission. Non-
   regression guard for evaluations that ship the marginal alone.

cargo test --lib: 438 -> 440 (no regressions).
Mirror CPU commit d6d2c6d into the CUDA backend's MT=91 (continuum
inelastic) outgoing-energy branch. Both the persistent kernel
(`transport.cu`) and the event-based kernel
(`transport_event_based.cu::gr_inelastic_event`) synthesised
`Q = -(e_cm - e_out)` so the downstream two-body kinematics
(`e_n_cm = e_cm_out * A/(A+1)`) would land at `e_n_cm = e_out * A/(A+1)`.
That double-counts the partition.

For MT=91 the sampled E_out from a `center_of_mass=true` product
(OpenMC's convention for Law 4 / Law 61) is already the neutron's CM
kinetic energy directly — the recoil's share is implicit in the
tabulation. Invert the partition into the synthesised q_eff so the
existing two-body math returns `e_n_cm = e_out` exactly:

    e_cm        = E * A / (A + 1)              ; CM energy of relative motion (eV)
    e_neutron_cm = clamp(sampled E_out)
    q_eff       = e_neutron_cm * (A + 1) / A − e_cm

Then `e_cm_out = e_cm + q_eff = e_neutron_cm * (A+1)/A` and
`e_n_cm = e_cm_out * A / (A+1) = e_neutron_cm`. The discrete-level
branch (MT=51..90) is untouched.

The Law 61 ("correlated") HDF5 loader fix on the CPU host
(`hdf5_reader.rs` in d6d2c6d) flows automatically into the GPU path
because `gpu_per_nuclide.rs::build_per_nuclide` consumes the
`nuc.inelastic_continuum_edist` populated by the same host loader. No
GPU-side host changes needed for that piece.

Verification (RTX A1000 laptop, ENDF/B-VIII.1, Godiva b=80 i=20 p=20000
s=42 r=15) — combined with the 77a5b28 mirror in the previous commit:
  CPU k_eff:        0.99869 ± 110 pcm
  GPU k_eff:        0.99894 ± 113 pcm
  Δ k_eff:          +25 pcm   (well within combined seed noise)
  CPU MT=91/inel:   44.1 %    (matches OpenMC reference)
  GPU inel/source:  0.5372    (CPU 0.5385, Δ −0.24 %)
  GPU ⟨E_out⟩:      7.836e5 eV (CPU 7.811e5 eV, Δ +0.3 %)
  GPU ⟨|Q|⟩ inel:   9.422e5 eV (CPU 9.376e5 eV, Δ +0.5 %)

cargo test --lib --features cuda: 449 / 449 green
cargo test --lib:                 436 / 436 green
Ports the CPU mu-coupling fix (commits 085a93a + a04b73f) to both
NVRTC-compiled device kernels — replaces the isotropic-in-CM MT=91
outgoing-angle draw with the correlated mu(E') sample loaded from
ENDF/B-VIII.1's `CorrelatedAngleEnergy` representation.

Data plumbing (host)
====================
- `TabularEdistSlicesGpu` (gpu_per_nuclide.rs) carries three new
  optional CudaSlices when the nuclide ships Law 61:
    * `mu_data`           — flat (mu, pdf, cdf) triples across all
                            (E_in_bin, E_out_bin) tables
    * `mu_offsets`        — cumulative triple-index offsets per table
                            (length = total_eout_bins + 1)
    * `mu_e_in_starts`    — per-E_in starting index in mu_offsets
                            (length = n_inc + 1)
- `build_tabular_edist` extended to optionally accept the
  `CorrelatedAngleEnergy` and pack the three slabs.
- `AssembledBundleA6Cat` + `assemble_a6_cat` produce per-nuc base
  pointer vecs (`inel91_mu_*_ptrs_vec`) and the per-nuc has-flag
  (`inel91_mu_has_vec`).
- `GpuNuclideData` (gpu_transport.rs) carries the per-nuc CudaSlices
  for the three pointer arrays plus the has-flag.
- N_PARAMS bumped 188 → 192 atomically across `gpu_transport.rs:18`
  and `gpu/cuda/transport.cu` per the CLAUDE.md invariant. Four new
  slots: P_INEL91_MU_HAS_NUC, P_INEL91_MU_DATA_PTRS,
  P_INEL91_MU_OFFSETS_PTRS, P_INEL91_MU_E_IN_START_PTRS.

Device sampling
===============
- `sample_inel91_mu_at(hit_nuc, e_in_bin, e_out_bin, e_out_frac, rng,
  p)` in transport.cu mirrors CPU `CorrelatedAngleEnergy::sample_mu_at`:
  stochastic E_out bin pick (low vs high based on `e_out_frac`),
  CDF-invert the chosen (E_in_bin, E_out_bin) mu table via the same
  binary-search + linear-interp scheme as `TabularMuDist::sample_with_xi`.
  Falls back to isotropic-in-CM when `P_INEL91_MU_HAS_NUC[nuc] == 0`
  (matches CPU fallback for pre-Law-61 evaluations).
- `sample_inel91_energy_with_mu` is a thin wrapper around
  `sample_inel91_energy` that also writes (E_in_bin, E_out_bin,
  E_out_frac) to caller-supplied pointers — the original sampler stays
  for fission χ and other call sites.
- MT=91 branch in `transport.cu` (persistent kernel) and
  `transport_event_based.cu::gr_inelastic_event` (event-based kernel)
  call the new sampler, stash the bin triple, and feed it into the
  angular dispatch. Precedence:
    * MT=51..90 with stored per-level angular → use that
    * MT=91 with stashed bin AND mu coupling   → sample_inel91_mu_at
    * else (no Law 61, n_inc91=0, etc.)        → isotropic-in-CM
  Mirrors CPU `inelastic_scatter_with_mu` exactly.

NVRTC scope
===========
Both `.cu` files are NVRTC-compiled with only `-DMAX_NUC_PER_MAT=N`
flag (no `--extended-lambda`). All new code stays in plain
`__device__ __forceinline__` style — no lambdas, no C++17 patterns,
no template metaprogramming. Helpers in `transport.cu` are visible
to `transport_event_based.cu` via the existing kernel-source
concatenation in `gpu_recursive::assemble_kernel_source`.

Verification
============
- `cargo check --features cuda` clean.
- `cargo test --lib`            : 438 / 438 (CPU unchanged).
- `cargo test --lib --features cuda` count: unchanged; the kernel
  branch only fires on devices that present `mu_has[nuc] == 1`, which
  is the common case for U-235/238/234 in ENDF/B-VIII.1.

GPU runtime verification pending hardware on the building machine —
the equivalent CPU change closed ~40 pcm of the Godiva residual; the
GPU should now follow CPU to within combined seed-to-seed noise on
fast-metal cases (down from the +25 pcm CPU-GPU gap measured in
e293f80's report, which came from the GPU still using isotropic-CM
where CPU was Law-61-correlated).

Refs:
- docs/engine-vs-openmc-bias-investigation.md (KM section).
- 085a93a (hdf5 loader), a04b73f (collision plumbing).
- 894f007, e293f80 (prior GPU mirrors of σ interp + MT=91 partition).
Lift the physics-side SB additions from db6b547 onto the
pre-orchestration baseline (no benchmark/ pipeline module, no
benchmark_runner binary, no force_rebuild watchdog).

Kernel surface (transport_event_based.cu):
- gr_init_stacks / gr_refill_dead take d_weight, seed w = 1.0.
- gr_trace_and_sample gains an SB branch (gated on
  P_SURVIVAL_BIAS_ENABLED): implicit capture, Bernoulli-banked
  fission, Russian roulette before the partition atomic.

Host surface:
- N_PARAMS 192 -> 195 in gpu_transport.rs and transport.cu.
  Slots 192-194 = P_SURVIVAL_BIAS_ENABLED / P_W_MIN / P_W_SURVIVE.
  Defaults in build_transport_params_vec keep the analog path
  bit-for-bit when no caller opts in.
- gpu_recursive.rs: transport_recursive_with_buffers takes
  Option<&SurvivalBiasing>, overwrites the three slots, sets
  sb_enabled; gr_fission_event skipped under SB.
- dispatch.rs: CudaRunner::run forwards config.survival_biasing.

Test (cuda_runs.rs): cuda_survival_biasing_unbiased_godiva runs
HMF-001 case-1 analog vs SB on the same seed set and asserts
both pass LANL Table LIX and the analog-vs-SB parity is within
2 sigma_combined.

CPU mirror in simulate.rs::dispatch_real_collision was already
present pre-orchestration; nothing changes there.
Pre-orchestration icsbep_sweep.py constructs SimConfig through
the PyO3 `Settings` class, which previously had no field for
survival_biasing — every site hard-coded `survival_biasing: None`
in the Rust binding layer, so the GPU SB path was unreachable
from the Python sweep harness even after the kernel landed.

PySettings gains an `Option<(f64, f64)>` survival_biasing field
(w_min, w_survive). The three SimConfig construction sites in
`bindings/python/src/lib.rs` (run_eigenvalue,
run_coupled_neutron_photon, run_icsbep_case) read it and pass
`SurvivalBiasing { w_min, w_survive }` into SimConfig.

icsbep_sweep.py CLI: --survival-bias (default) / --no-survival-bias
mutually-exclusive group. Default ON matches the post-db6b547
intent (variance-reducing path for benchmarks). Banner prints the
active mode. Forwarded into Settings via base_settings
propagation to keep per-seed settings consistent.

run_benchmark.ps1 CLI: -NoSurvivalBias switch. Default ON.
Banner row mirrors icsbep_sweep.py.
CLAUDE.md / STATUS.md previously described the in-process
benchmark pipeline as the path forward. With orchestration
rolled back to subprocess-per-case (icsbep_sweep.py + Python
loop), the docs now:

- Note N_PARAMS = 195 with the SB slot layout (192-194).
- Flag the subprocess-per-case driver as the canonical
  benchmark runner; force_rebuild watchdog and the benchmark/
  module are gone.
- STATUS.md gets a banner explaining why the rollback happened
  (force_rebuild() corrupted state on case-1 timeout).
- Test counts marked as needing re-verification on this branch
  (physics cherry-picks added MT=91 + SB parity tests on top of
  the 438 / 443 baseline).
Trivial clippy-style cleanup in vendored hardware-query-0.2.1:
the locally-collected `vulnerabilities` / `tpus` Vecs are never
mutated through the surrounding cfg blocks on this platform, so
the `mut` is dead. No behaviour change.
…ator

The in-process pipeline (benchmark_runner + src/benchmark/) and main's
parallel GPU survival-biasing impl (db6b547) are explicitly rejected.
This branch retains the subprocess-per-case driver and the survival-
biasing implementation from 9bc21c1. See CLAUDE.md and STATUS.md for
the rollback rationale (force_rebuild() watchdog corruption).
@sorcerer86pt
sorcerer86pt merged commit 8a601bf into main May 28, 2026
0 of 4 checks passed
@sorcerer86pt
sorcerer86pt deleted the drop-orchestration-keep-physics branch May 28, 2026 13:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant