Conversation
Material::calculate_xs dispatches on the particle type, so the neutron and photon routines below it each have a particle type they assume. That assumption is not written down anywhere and is not checked, and one caller does not hold it: the micro cross section branch in score_tracklength_tally_general and its collision counterpart call Particle::update_neutron_xs for whatever particle is being scored, whenever the tallied nuclide is absent from the scoring region. For a photon that evaluates a neutron cross section at a photon's energy and stores it in a cache only neutron scoring ever reads, so the value is discarded and the only trace is the work done to produce it. Guard those two call sites on the particle type, and assert the assumption in the four routines that hold it: Material::calculate_neutron_xs, Material::calculate_photon_xs, Particle::update_neutron_xs and PhotonInteraction::calculate_xs. Note that under temperature interpolation the discarded evaluation consumed a pseudorandom number, since Nuclide::calculate_xs samples between the bracketing temperatures. Removing it shifts the random number stream, so a model combining photon transport, temperature interpolation and a nuclide bin tally with multiply_density off will no longer reproduce earlier results bit for bit. No test in the suite combines those. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JxR1sQWWnALXj39rX12v7c
GuySten
force-pushed
the
claude/assert-xs-particle-type
branch
6 times, most recently
from
September 14, 2026 17:08
047ad5c to
e83a7ec
Compare
Each particle carries two micro cross section caches: neutron_xs_, sized by data::nuclides, and photon_xs_, sized by data::elements because photon data does not distinguish isotopes. Which particle type each applies to, and which index space addresses it, are assumptions nothing states or checks, and three places get them wrong. Tally scoring indexes the photon cache with a nuclide index. The two index spaces coincide only when every element contributes exactly one nuclide, so a material of U235, U238 and O16 scores oxygen's cross section for U238 and reads past the end of the vector for O16. A tally may also name a nuclide no material contains, which adds a nuclide without adding an element, so even a single nuclide material reaches out of bounds. Add data::nuclide_to_element, recorded where the element is already being resolved, and resolve the nuclide once at the top of score_general_ce_nonanalog, where the particle and the nuclide are fixed for the whole call. The four lookups then address the cache by element index the way every other caller in the tree already does, so there is no second way to reach it and nothing new to get wrong. The two accessors name their parameters i_element and i_nuclide rather than i. The branch that refreshes a stale cache when the tallied nuclide is absent from the scoring region only handles neutrons, so a photon scored whatever energy it last collided at. Give it Particle::update_photon_xs, which refreshes an element's entry unless it already holds the particle's energy. It takes an element index rather than a nuclide one, unlike update_neutron_xs next to it, because that is the index space photon data is tabulated in; the caller that has a nuclide resolves it the same way scoring does. That same branch called Particle::update_neutron_xs for whatever particle was being scored. For a photon that evaluated a neutron cross section at a photon's energy and stored it where only neutron scoring reads, so the value was discarded. Guard it on the particle type, and assert the particle type in the four routines that assume one: Material::calculate_neutron_xs, Material::calculate_photon_xs, Particle::update_neutron_xs and PhotonInteraction::calculate_xs. The two estimators carried identical copies of that branch, so the guard would have doubled it. Both now call one update_absent_nuclide_xs, which is shorter than what either of them started with. Note that under temperature interpolation the discarded neutron evaluation consumed a pseudorandom number, since Nuclide::calculate_xs samples between the bracketing temperatures. Removing it shifts the random number stream, so a model combining photon transport, temperature interpolation and a nuclide bin tally with multiply_density off will not reproduce earlier results bit for bit. The absent nuclide test needs multiply_density off. An absent nuclide has an atom density of zero, so with it on the score is zero whatever the cache held, which is both a test that cannot fail for the right reason and the branch that refreshes the cache in the first place. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JxR1sQWWnALXj39rX12v7c
GuySten
force-pushed
the
claude/assert-xs-particle-type
branch
from
September 14, 2026 17:57
e83a7ec to
e94fcbd
Compare
GuySten
marked this pull request as ready for review
September 14, 2026 18:21
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Each particle carries two microscopic cross section caches:
Photon data doesn't distinguish isotopes, so the two are addressed by different index spaces, and each applies to one particle type. None of that is stated or checked anywhere — and three places get it wrong. They look like separate bugs but they're one unexpressed invariant, so this fixes them together.
1. The photon cache is indexed with a nuclide index
p.photon_xs(i_nuclide)at four scoring sites (604,628,648,1055). The index spaces coincide only when every element contributes exactly one nuclide. For UO2 — nuclidesU235=0, U238=1, O16=2; elementsU=0, O=1— scoring U238 reads oxygen's cross section, and scoring O16 reads past the end of a size-2 vector.A tally can also name a nuclide no material contains (
Tally::set_nuclidescallsopenmc_load_nuclidefor unknown names), which adds a nuclide without adding an element. So even a single-nuclide material goes out of bounds.This adds
data::nuclide_to_element, recorded inopenmc_load_nuclidewhere the element is already being resolved, and routes all four lookups through one accessor:The mapping is filled on the same branch that reads the element's photon data, so it is populated exactly when
data::elementsis, holdsC_NONEfor a nuclide whose element was never loaded, and is cleared infree_memory_photonalongsidedata::elements. Reaching it therefore cannot outlive the data it indexes.Particle::photon_xshad only a non-constoverload, so scoring — which holds aconst Particle&— could not have gone through a helper at all. Added theconstone next to it;neutron_xsalready had both.2. The stale-cache refresh only handles neutrons
When the tallied nuclide is absent from the scoring region and
multiply_densityis off, the code refreshes the micro cross section cache — for neutrons only. A photon scored whatever energy it last collided at, since nothing had updated that element's entry since its last collision. Both estimators (score_tracklength_tally_generalandscore_collision_tally) now refresh the element cache too, guarded onlast_Ethe way the collision path already does.3. The refresh called the neutron routine for any particle
Particle::update_neutron_xswas called regardless of particle type. For a photon that evaluated a neutron cross section at a photon's energy and stored it where only neutron scoring reads, so the value was discarded. Guarded on the particle type, and the assumption is now asserted in the four routines that hold it:Material::calculate_neutron_xs,Material::calculate_photon_xs,Particle::update_neutron_xs,PhotonInteraction::calculate_xs.Assertions rather than silent early returns: a caller asking for a neutron cross section while transporting a photon has a logic error, and returning quietly would cost a branch in the hottest loop in the code to make it permanently invisible — which is how this survived. They're free under
NDEBUG, and since CI passes-DOPENMC_ENABLE_STRICT_FP=on, which strips-DNDEBUGfrom theRelWithDebInfoflags, they're live across the whole regression suite.Note on reproducibility
Under
temperature_method = 'interpolation'the discarded neutron evaluation consumed a pseudorandom number —Nuclide::calculate_xssamples between bracketing temperatures withif (f > prn(p.current_seed())) ++i_temp;(src/nuclide.cpp:809). Removing it shifts the stream, so a model combining photon transport, temperature interpolation and a nuclide-bin tally withmultiply_densityoff will not reproduce earlier results bit for bit. The physics is unchanged — the discarded cross section was never read. No test in the suite combines those three.Testing
tests/unit_tests/test_photon_micro_xs.pyuses a data-independent invariant: two isotopes of the same element at the same atom density must score the same microscopic cross section, whatever that cross section is. The first test talliesU235andU238in a material holding both and requires the two bins to agree torel=1e-12; before the fixU238read the next element's entry, or past the end of the vector when there wasn't one. The second talliesU238in a material ofU235only — a nuclide with no element of its own, whose index lands outside the cache entirely — and requires a non-zero score, which is the correct answer because the element's data is present either way.No nuclear data was available in my environment, so those two tests could not be executed as written. The identical assertions were verified against a synthetic photon-only library on a branch that can run without neutron data:
0.006974vs0.015110, ratio2.166668— exactly Z(Al)/Z(C) = 13/6, i.e. C13 was reading aluminium. After the fix: identical to 12 significant figures.-D_GLIBCXX_ASSERTIONSbuild, which aborts onvector<ElementMicroXS>::operator[]: Assertion '__n < this->size()' failed. After the fix, clean.C++ unit tests pass in a CI-configuration build (
RelWithDebInfo,OPENMC_ENABLE_STRICT_FP=on, assertions confirmed live incompile_commands.json).Checklist