Skip to content

Rust ENDF-6 parser crate with Python bindings and a parity harness - #17

Open
shimwell wants to merge 44 commits into
local-developfrom
claude/rust-rewrite-python-layer-1upmmo
Open

Rust ENDF-6 parser crate with Python bindings and a parity harness#17
shimwell wants to merge 44 commits into
local-developfrom
claude/rust-rewrite-python-layer-1upmmo

Conversation

@shimwell

@shimwell shimwell commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Moves the reader to Rust, so the same parser can serve consumers that never load Python — in particular a converter emitting Arrow tables directly.

Additive. No Python is changed or removed except to teach the package to read .xz files; the existing pytest suite still gives 168 passed, 2 skipped, and 235 passed once the extension module is built.

Layout

crates/
├── endf/      the parser. No Arrow, no Python, no dependencies at all.
└── endf-py/   PyO3 bindings. Thin: every type forwards to the Rust one.

endf describes the formats and nothing more. A simulation-ready projection — reconstructed resonances, unionised grids, an Arrow schema — is a consumer's concern, so it can depend on arrow-rs without that cost reaching everyone who just wants to read a file.

What is ported

Every module of the package, module for module, except where noted under Gaps:

Format layer records, function, material, and MF 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 13, 14, 15, 23, 26, 27, 28, 33, 34, 40
Processed formats ace (Type 1 tables), urr
Distributions univariate, angle_energy, plus AngleDistribution and EnergyDistribution spanning ENDF and ACE alike
Objects product, reaction, incident_neutron, incident_photon, decay, radionuclide_production, chain
Support data, fission_energy, njoy

Each covers every representation the file defines, not only the ones the fixtures happen to use. About 13,300 lines of Rust, with 2,300 of comment and 2,300 of unit test alongside.

The Python extension

endf-py exposes both layers a consumer reads.

The concrete types come across as classes: Material, Tabulated1D, Tabulated2D, CrossSection, Product, Reaction, IncidentNeutron, IncidentPhoton, Decay, Chain, AceTable, FissionProductYields, RadionuclideProduction. Beside them the free functions — float_endf, int_endf, get_materials, get_tables, ace_tables_from_string, reaction_name, reaction_mt, photon_reaction_name, photon_reaction_mt, gnds_name, zam, temperature_str, decay_modes, normalise_branch_ratios, radionuclide_production, isomer_table, level_to_isomeric_state — and the constant tables ATOMIC_SYMBOL, SUM_RULES, INTERPOLATION_SCHEME, FISSION_MTS, EV_PER_MEV, K_BOLTZMANN.

The sum types come across as dicts tagged with a kind key rather than as a class per variant:

>>> neutron[51].products[0].distribution[0]
{'kind': 'uncorrelated',
 'angle': {'energy': [...], 'mu': [{'kind': 'legendre', 'coefficients': [...]}, ...]},
 'energy': {'kind': 'level-inelastic', 'threshold': 1.4e6, 'mass_ratio': 0.98}}

kind is exactly the discriminant an Arrow union column needs, and the alternative is a dozen wrapper classes expressing nothing more.

Material.section_data is a drop-in. Every one of the 414 sections across the fixtures comes back as the same dictionary the Python reader builds, keyed by the same ENDF field names, so code reaching into material[3, 1]['sigma'] runs unchanged against either reader. Material.interpret() is there too, picking IncidentNeutron or IncidentPhoton by NSUB.

The upstream quirks come with them, because matching means matching: MT=458 reports ZA as a float since it is read from a CONT record, MF=7 MT=4 stores the outer LT on each additional temperature rather than the LI it read, an unresolved range with LRF=1 is dispatched past unread, and a decay record too short for its internal conversion coefficients yields an empty tuple rather than a zero. Each is commented at its site with the issue that tracks it.

How it is tested

tools/dump_golden.py writes what the Python reader produces for a fixture; crates/endf/tests/golden.rs reads each dump, runs the Rust reader over the file it names, builds the same path -> value map from its own parse, and compares the two maps whole. A field renamed, dropped or added shows up as a path on one side and not the other.

Values are compared bit-for-bit. The dump records the shortest round-tripping decimal and both readers round correctly, so any difference is real. Only computed values use a 1e-12 relative tolerance, and is_interpolated lists exactly which: sampled interpolation; the MF=10 yields, which divide two interpolated cross sections; the propagated uncertainties, where uncertainties accumulates a variance and takes its square root; and the forward-scattered fraction with the removal cross section that folds it in, because NumPy re-associated the Clenshaw recurrence between 2.2 and 2.4. Nothing that comes off the file is compared loosely.

The dump goes well past the parsed sections — every reaction, every nuclide, the MF=8/9/10 join, decay data with its source distributions, photon data with its atomic relaxation, and a whole depletion chain through a KIND chain golden naming ten decay and two neutron evaluations. For ACE it walks the AND block, the whole of DLW following each reaction's linked list, every reaction including elastic, and the nuclide they belong to. The NJOY input deck is held to Python's byte for byte.

The bindings are tested the same way: tests/test_rust_bindings.py runs the extension and the pure-Python reader over the same fixtures and compares, rather than asserting values written down by hand. It skips itself when the module is not built.

Value paths compared 38,129 across 28 goldens
Tests 131 Rust unit + 6 integration (debug and release), 67 binding
cargo clippy --all-targets -- -D warnings clean
cargo fmt, ruff check, ruff format clean
pytest 235 passed, 2 skipped

Three kinds of coverage are pinned and asserted rather than described: UNCOVERED_BY_ANY_FIXTURE, the parsers nothing exercises (MF40 alone, now); DISTRIBUTION_SHAPES, every angular, energy and joint angle-energy shape the dumpers can write; and SECTIONS_WITHOUT_A_DICT, now empty, so a section_data projection that stops being built fails rather than quietly disappearing. None can drift in either direction.

CI is in .github/workflows/rust.yml, three jobs: the crate (fmt, clippy, test in both profiles); the goldens, which python tools/dump_golden.py --check holds to what the reader produces; and the bindings, built as an abi3 wheel with maturin on 3.10 and 3.13 and then tested.

Fixtures

Twenty-three ENDF evaluations and four ACE tables, stored xz-compressed: an evaluation is highly repetitive and goes about six to one, the dumps about seven, so 9.4 MB of text is 1.5 MB of blobs and 80,000 lines leave the diff. Python reads them through endf.fileutils.open_text, which handles .xz and leaves anything else alone; Rust reads them with lzma-rs, a pure-Rust dev-dependency, so the crate stays dependency-free for anything that uses it. The extension carries it as a real dependency, so a path that reads in one reader reads in the other.

tools/trim_endf.py cuts an evaluation to the sections under test keeping the records valid — U235 is 36 MB whole and 528 KB here. Where no real file small enough to keep holds a shape, one is built: make_urr_ace.py for the unresolved resonance block, make_laws_ace.py for the ACE laws Li6 does not use, make_denormal_ace.py for the float form NJOY writes for a denormal, make_nfy_endf.py for the fission product yields, and make_shapes_endf.py for MF2 Breit-Wigner, MF5 LF=12, MF6 LANG=2 and LAW=6, and MF13. The last two share the record writer in tools/endf_writer.py. Values are invented; the layout is the format's, which is the part both readers are held to.

Gaps, deliberately not hidden

  • MF40 has no fixture — it needs an evaluation with radionuclide production covariances. The MF33 subsection parser it delegates to is covered.
  • MF2 has real Reich-Moore parameters, a Case C unresolved region and a synthetic Breit-Wigner section, but R-matrix limited (LRF=7) and unresolved Cases A and B are untested. Cases A and B are unreachable through the current dispatch anyway — see Unresolved resonance ranges with LRF=1 are silently dropped (parse_mf2 dispatches on LRF where it means LRU) #15.
  • No fissile or photoatomic ACE table, so the ACE fission path, MFTYPE=13 photon production, IncidentPhoton::from_ace and a second temperature for add_temperature_from_ace are written but unexercised.
  • Not ported, on purpose. _add_compton_profiles and _add_bremsstrahlung attach data from a shipped HDF5 file resampled with a cubic spline — auxiliary data, and it would cost the crate an HDF5 reader and a spline. The chain's XML serialisation needs an XML writer and form_matrix needs sparse linear algebra. None of the four reads a nuclear data format.
  • Nothing emits Arrow yet. The types were shaped to map onto the target schema — MT=458's polynomial/tabulated split is exactly a kind column, AngleEnergy's four variants are the four shapes distributions.arrow has columns for — but the RecordBatch construction is not here.

Upstream issues found while porting

Twelve. Each is reproduced in Rust deliberately, with a comment linking the issue, so the two readers agree until they are settled:

#15, #18 and #21 are the ones worth attention. Case A and Case B are how most actinides write their URR, and the range comes back with only its header and no error — and with the file position wrong, so a later range is misparsed. In #18 every angular-distribution covariance is read off the stream and thrown away. In #21 the same removal_xs call gives a different answer depending on what ran before it.

Where there is no behaviour worth matching, the port does the defensible thing and says so in a comment: it refuses ACE law 5 by name (#19), computes the forward fraction properly for the ACE shapes (#21), stops the stand-in walk at the edge of the table (#22), and treats a zero half-life as unevaluated rather than as instant decay (#23).

Five differences were found in the other direction, in code written here, all by the exhaustive comparisons:

  • temperature_str(1200.5) gave 1201K against Python's 1200K — Python's round breaks ties to even, f64::round away from zero. That string keys which temperature a table belongs to.
  • The univariate CDFs differed in the last two digits for linear-log and log-linear, because Python subtracts logs (np.diff(np.log(x))) where the port took the log of a ratio.
  • The Legendre recurrence differed in the last two digits of every forward-scattered fraction: NumPy 2 forms the Clenshaw ratios before multiplying, where the port multiplied then divided.
  • MF=8 MT=457 discrete radiation records are written with NT = 6 or NT = 12 within a single spectrum. The port returned zeros for the absent internal conversion coefficients where Python returns nothing — a zero conversion coefficient is a physical claim, and the wrong one.
  • The NJOY deck wrote a temperature as 900 where Python writes 900.0. Python's str always writes a fractional part; Rust's shortest form does not. The same trap breaks the decay mode encoding, which reads RTYP by stripping zeros off the formatted float, so python_float_str lives in data and both use it.

All five fixed and pinned.

Building

cargo test                                      # parser and parity tests
cargo clippy --all-targets -- -D warnings
python tools/dump_golden.py                     # regenerate the golden files
python tools/dump_golden.py --check             # or just check them, as CI does
maturin develop -m crates/endf-py/Cargo.toml    # the extension module
python -m pytest                                # including the binding tests

claude added 29 commits August 9, 2026 07:20
Scaffolds the Rust layer for the port, structured so the two readers can
run side by side while it proceeds file by file.

crates/endf is the parser: the ENDF-6 record primitives, the tabulated
function types, section splitting for every MF/MT, and MF3 as the worked
example of a ported file. It has no dependencies — not Arrow, not Python
— because a simulation-ready projection of this data belongs in the
consumer that needs it, not in a crate everyone reading a file has to
pay for.

crates/endf-py wraps it with PyO3 so the Python API can keep working
while the parser moves underneath it.

Files without a Rust parser still split correctly and keep their text as
Section::Unparsed, so nothing is lost while the port is incomplete.

The parity harness is the load-bearing part. tools/dump_golden.py writes
what the Python reader produces for an evaluation; tests/golden.rs reads
every such dump, runs the Rust reader over the file it names, and
compares. Parsed values are compared bit-for-bit — the dump records the
shortest round-tripping decimal and both readers round correctly, so any
difference at all is real. Interpolation is sampled at bin midpoints
either side of every region boundary and held to 1e-12. Section line
counts are checked for every MF, so a new evaluation is useful coverage
the day it is added, long before every file is ported.

Adding an evaluation is dropping it in tests/data/ and regenerating; the
Rust test discovers dumps and follows their SOURCE line. The coverage
still wanted — resonance formalisms, MF6 distribution laws, thermal
scattering, decay and fission yields, photo-atomic data, and the same
nuclide across ENDF/B, JEFF, JENDL and TENDL — is listed as a checklist
in crates/endf/tests/golden/README.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
The golden format was record-per-MF, which meant every ported file
needed a new record type on both sides and a new arm in the test's
walker. Replaced with a single generic record:

    V <path> <F|I|T> <values...>

Both readers now build a path -> value map and the test compares the two
maps whole. A field that is renamed, dropped or added shows up as a path
on one side and not the other, so the test catches it without knowing
what the field was for. Strings are hex-encoded because ENDF text fields
are fixed-width and carry significant spaces.

Porting a file is now two mirrored dump functions and nothing else.

MF1 is ported on top of that: MT451 descriptive data and directory,
MT452/456 nu-bar, MT455 delayed neutrons, MT458 fission energy release,
MT460 delayed photons. 663 paths compared against the Python reader for
the one fixture, all matching.

MT458's components keep the shape the format stores them in — a
component is polynomial or tabulated, per component, and evaluations mix
the two within one file. That is the same distinction the converted data
has to carry, so preserving it here means it does not have to be
recovered later.

Two upstream inconsistencies are reproduced deliberately, each commented
at the site with a link: MT458 reporting ZA as a float (#14), and the
INTG record's column index (#11).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
Covers every representation the format defines: Breit-Wigner (LRF=1 and
LRF=2, which are read identically), Reich-Moore, R-matrix limited with
its background R-matrix and phase-shift extensions, and the unresolved
region in all three cases. Adler-Adler (LRF=4) errors, matching where
the Python reader raises NotImplementedError.

The unresolved dispatch reproduces upstream issue #15 deliberately: the
Python reader branches on LRF where it means LRU, so a URR written with
LRF=1 — Case A and Case B, how most actinides write theirs — falls
through unread. The Rust reader does the same so the two agree, with a
test pinning the behaviour so that fixing it upstream surfaces as a
failing test rather than a silent change. Case A and Case B are
implemented and switch on with the one-line dispatch fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
MF4 angular distributions in all four LTT forms, including reading past
the obsolete energy transformation matrix. MF5 energy distributions in
every LF law: arbitrary tabulated, general evaporation, Maxwellian,
evaporation, Watt and Madland-Nix. MF6 product distributions in every
LAW: continuum energy-angle (which carries the Legendre, Kalbach-Mann
and tabulated angular representations), discrete two-body, charged
particle elastic, n-body phase space and laboratory energy-angle.

These are the shapes an Arrow projection of this data needs columns for,
so they keep the structure the format gives them rather than being
flattened here.

Two upstream behaviours reproduced deliberately: MF4 keeps only the last
T and LT across incident energies, as the Python reader does, and an
unrecognised MF5 law is an error where the Python reader raises
UnboundLocalError.

The n-095_Am_244 fixture is now fully ported — every MF it contains has
a Rust parser — so the test that checks unported sections keep their
text moved to the photo-atomic fixture, where MF23 and MF27 are still
outstanding.

2985 paths compared against the Python reader across six fixtures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
MF7 thermal scattering: coherent and incoherent elastic (MT=2), the
incoherent inelastic scattering law with its per-temperature blocks and
effective temperatures (MT=4), and the general information file
(MT=451).

MF8 in all three shapes: radioactive nuclide production, fission product
yields (MT=454 and MT=459), and decay data (MT=457) with its decay
modes, discrete and continuous spectra, and both covariance forms. MF9
and MF10 give the isomeric multiplicities and production cross sections
that isomeric branching is derived from.

Together these are what a transmutation network is built out of, so they
keep the structure the format gives them.

MF7's per-temperature LI is dropped upstream in favour of the outer LT
(#16); reproduced with a comment pointing there.

3057 paths compared against the Python reader across six fixtures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
Energy released by fission, by component, built on the MF=1 MT=458
parser. Covers the polynomial and tabulated forms, the Sher-Beck energy
dependence for evaluations that give a single coefficient, and the
ENDF/B-VII.1 units correction for second-order coefficients left in MeV.

Takes nu-bar as a parameter rather than an IncidentNeutron. Sher-Beck is
the only thing that needs it, it needs only the prompt or total neutron
yield, and passing that directly keeps this module off the high-level
layer, which is not ported yet.

Pinned against the Python implementation at three incident energies —
thermal, 1 MeV and 14 MeV — for every component plus the total and
prompt Q. The golden harness covers parsed sections; derived quantities
like these are computed rather than read, so they are pinned separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
ruff check was already clean; this is formatting only. The repository
does not use ruff (no config, and 40 of 42 source files would be
restyled), so this is confined to the one file added by the port rather
than imposed on existing source.

Verified to change no output: all six golden files are byte-identical
after regenerating.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
Grouped into two modules rather than one per file, because each group
describes one thing between them. photon.rs is MF12 to MF15: how many
photons a reaction makes, the cross section for making them, where they
go and with what energy. atomic.rs is MF23, 26, 27 and 28: the photon
and electron interaction cross sections, the secondary distributions of
the electro-atomic ones, the form factors that modify coherent and
incoherent scattering, and how the ionised atom relaxes afterwards.

MF26 reuses MF6's LAW=1 and LAW=2 readers rather than restating them, so
those two are now public and the golden dumper's MF6 distribution walk
is factored out and shared.

This retires the last of the fixtures' unparsed sections: the
photo-atomic and atomic-relaxation files that came in with local-develop
are now read rather than only counted. 3184 paths compared, up from
3057, over the same six fixtures.

Two test changes follow from that. `unported_files_keep_their_text` was
asserting that some fixture still had an unparsed section, which is no
longer true, so it now builds a synthetic MF=34 section instead — a test
that depends on coverage being incomplete stops testing anything the
moment that stops holding. And a new `every_fixture_section_has_a_parser`
guards the other direction: a fixture added later that contains a file
with no Rust parser now says so by name rather than the coverage quietly
slipping.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
Grouped into one module because MF=40 reuses MF=33's subsection format
verbatim and MF=34 is the same idea applied to angular distributions.
Covers the NC-type derived covariances, the NI-type explicit ones in
every LB layout, and MF=40's per-product subsections. Adds
Reader::peek_cont_record, since MF=33's LB says how to read the record
it is written in — the Python reader does the same with tell and seek.

This completes the ENDF format layer: every file the Python package
parses now has a Rust parser.

Two upstream defects reproduced deliberately, each commented with a
link. MF=33 appends its LTY=0 sub-subsections twice (#12). MF=34 both
discards every subsection it parses and fills LB with LS (#18, filed
from this work) — the records are still consumed, so nothing
desynchronises, and a test pins the behaviour so correcting it upstream
surfaces as a failure rather than a silent change.

Half the ported parsers have no fixture that exercises them: MF 6, 7,
12, 13, 14, 15, 26, 33, 34 and 40 have never been run against a real
evaluation. That list is now pinned in golden.rs as
UNCOVERED_BY_ANY_FIXTURE and checked, so it is a maintained fact rather
than a remark in a commit message, and the test fails the moment a
fixture starts covering one. The golden README says the same in prose,
with the fixtures that would close each gap.

The synthetic unparsed-section test moved from MF=34 to MF=32, which
neither reader parses, so it no longer needs revisiting each time a file
is ported.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
Half the ported parsers had never been run against a real evaluation.
This closes MF 6, 7, 12, 14, 15, 26, 33 and 34, leaving only MF13 and
MF40. 22563 paths compared across eleven fixtures, up from 3184 across
six, and every one matches the Python reader on first contact.

  n-003_Li_006_trimmed   MF6 LAW=2 and LAW=4, MF12, MF14, MF33
  n-026_Fe_056_trimmed   MF2 Reich-Moore, MF6 LAW=1, MF12/14, MF33
  n-092_U_235_trimmed    MF2 Reich-Moore and a Case C unresolved
                         region, MF8, MF10, MF15, MF34
  e-001_H_000            MF26 in all three of its laws, MF23
  tsl-s-CH4              MF7 MT=2 and MT=4

MF2 in particular went from a single LRU=0/LRF=0 range — a scattering
radius and nothing else — to real Reich-Moore parameters and an
unresolved region.

tools/trim_endf.py cuts an evaluation down to chosen sections, keeping
the record structure valid. Full files are impractical as fixtures: U235
is 36 MB whole and 451 KB with ten sections kept. Sections are kept
whole, never truncated, because a truncated one leaves its own NP and NE
counts describing records that are no longer there.

The sampled-interpolation comparison needed a fix the new data exposed.
A tabulated S(alpha, beta) holds zeros, and log-linear interpolation
across one gives NaN — in both languages alike, which is the two readers
agreeing about a real property of the evaluation. Comparing with
subtraction failed on NaN != NaN and reported a difference where there
was none. It now compares NaN against NaN and infinities exactly, since
what is under test is parity and not whether the answer is finite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
The element and sum-rule tables are generated from the Python package's
data.py rather than retyped, so the two cannot drift apart through a
transcription slip. The functions built on them — gnds_name, zam,
atomic_number, sum_rule, temperature_str — are hand-written, and were
cross-checked against Python over an exhaustive sweep: every atomic
number crossed with several mass numbers and metastable states, every
sum rule, and a range of temperatures. 1094 cases, identical.

That sweep found a real difference. temperature_str(1200.5) gave 1201K
in Rust and 1200K in Python: Python's round breaks ties to even and
f64::round breaks them away from zero. The string is used as a key for
which temperature a table belongs to, so the two readers would have
disagreed about that rather than merely rounding differently. Fixed to
round half to even, with the cases pinned in a test.

EV_PER_MEV moves here from fission_energy, which now uses the shared
one. data.py is a dependency of ace.py, which is next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
ACE is not ENDF — it is the processed, ready-to-sample form NJOY writes
— but several of the package's higher-level types can be built from
either, so the reader is needed before those can be ported. This covers
the Type 1 (ASCII) tables: both the 1.0 and 2.0 header styles, the
(IZ, AW) pairs, NXS, JXS and XSS, and the ZAID conventions for
metastable state in both the NNDC and MCNP schemes, including MCNP's two
exceptions around Am242m and its newer SZA form. Type 2 (binary) tables
are a different on-disk layout and are reported as unsupported rather
than misparsed.

ACE floats need their own parser rather than float_endf: NJOY drops the
'e' from values below 1e-100, writing 1.234567-120, but the field is not
fixed-width so the eleven-character rule does not apply.

Tested against a real Li6 table, which had to be brought in as a
fixture: porting a second file format with no coverage would have
reopened a larger gap than the one just closed. Git stores the 1.8 MB
file in 373 KB, the same as a gzip would cost, so it is committed plain
and neither reader needs to decompress.

The golden harness grew a second kind. An ACE golden opens with
KIND ace and the Rust side reads it through endf::ace; the map
comparison is now shared between the two paths rather than living inside
the ENDF walk. XSS is sampled — a spread across the array plus both ends
and every JXS entry point, mirrored index for index on both sides —
because recording all of it would make the golden the size of the table.
Corrupting a single sampled value fails the test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
The coverage section was rewritten when the fixtures landed but the
checklists below it were not, so the document asserted both that MF6 and
MF33 still needed fixtures and that they had them. Replaced with a table
of what each fixture actually covers and a list of what is genuinely
still missing — MF13 and MF40, Kalbach-Mann and n-body in MF6, the MF2
formalisms other than Reich-Moore, and libraries other than ENDF/B-VIII.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
Read from an ACE table rather than from ENDF — the ENDF form is MF=2
with LRU=2, which mf2 already handles; this is the processed form NJOY
writes and a transport code samples. Unblocked by the ace port.

Kept flat with an explicit [n_energy, 6, n_band] shape rather than
reshaped, because that is how the converted data stores it and reshaping
twice helps nobody.

Testing it needed a fixture that does not exist. No ACE file small
enough to keep in the repository has an unresolved block: the nuclides
that have one are heavy, their tables run to tens of megabytes, and an
ACE table cannot be trimmed because JXS holds absolute offsets into XSS.
So tools/make_urr_ace.py builds one. The values are invented but the
layout is the format's, which is what the reader is being held to, and
every value is distinct so a misplaced index shows rather than
cancelling out. It caught nothing, but it means the row-5 heating
conversion from MeV is now actually exercised rather than assumed.

Li6 covers the other path: JXS(23) is zero, and the reader reports no
unresolved region rather than reading whatever sits at index zero.

13 golden files, 22596 paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
Discrete, Tabular, Uniform and Mixture, with the CDFs integrated
analytically per interpolation law rather than numerically. These are
what angle_energy and the from_ace paths in mf4 and mf5 are built from,
so they come first.

XML serialisation is deliberately left out. The Python module reads and
writes these as OpenMC XML elements; adding that would mean an XML
dependency in a crate that has none, for a format the Arrow path never
touches.

Cross-checked against Python over every law, with CDFs, integrals and
normalisation printed to seventeen significant figures. That found a
difference worth having: Python computes np.diff(np.log(x)), a
difference of logs, where I had written log(x[i+1] / x[i]). The two are
equal in exact arithmetic and not in floating point, and it showed in
the last two digits of the linear-log and log-linear CDFs. Rewritten to
subtract logs the way the Python does, after which all 23 lines of the
sweep are identical.

Note that function.py writes the same idea the other way round —
log(x/xi) — in Tabulated1D, and the port matches it there. The rule is
to follow each module's own spelling, not to pick one and impose it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
`AngleDistribution` is the interpreted form of MF=4: the parsed section
says what the file holds, this says what it means. It comes from either
source — ENDF's Legendre coefficients and tabulated densities, or the
ACE AND block's equiprobable bins, tabulated densities and isotropic
flags.

Supporting pieces:

- `Legendre`, a Legendre series with numpy's evaluation and integration.
  Both follow `numpy.polynomial.legendre` expression for expression.
  Two spellings matter and are commented at the site: NumPy 2 forms the
  recurrence ratios before multiplying rather than after, which moves
  the last bits, and the constructor does not trim trailing zeros, which
  changes how many steps the recurrence takes.
- `Tabulated1D::from_ace`, with `ace_len` for callers stepping past a
  record.
- `c` on `Discrete` and `Tabular`: the CDF as the source file gave it,
  kept verbatim next to the computed `cdf()`.

The golden harness gains both paths — the interpreted distribution and
`forward_fraction` at four cutoffs for every MF=4 section, and every
locator in the ACE AND block. Li6's block covers all three ACE shapes.
25,208 paths across 13 fixtures, up from 22,596.

Found while cross-checking: my first `legval` used NumPy 1's
associativity and differed from Python in the last two digits of the
forward fraction. The golden comparison is exact, so it failed rather
than passing quietly.
`AngleEnergy` is the joint distribution of a secondary particle's angle
and energy as a processed ACE table holds it: uncorrelated, Kalbach-Mann,
correlated, or N-body phase space. It dispatches on the ACE law, so it
brings the ACE constructors of mf5.py with it — the Maxwell, evaporation
and Watt spectra, the discrete photon, level inelastic scattering and the
continuous tabulation, plus the incident grid and outgoing energy readers
laws 4, 44 and 61 share.

`EnergyDistribution` gains the three shapes that have no ENDF law and can
only come from a processed file. The enum now spans both sources, which
is what a consumer wants: one type for "the outgoing energy", however the
evaluation arrived.

ACE law 5 is refused by name rather than guessed at. The Python reader
dispatches it to a `GeneralEvaporation.from_ace` that does not exist and
dies with an AttributeError, so there is no behaviour to match; filed as
issue #19 and pinned by a test.

Coverage. The golden harness now walks the whole DLW block of every ACE
fixture, following the linked list each reaction carries, and checks the
applicability record and the interpreted distribution at each link. Li6
covers laws 3, 33 and 44; `tools/make_laws_ace.py` writes a synthetic
table for 2, 4, 7, 9, 11, 61 and 66, with the continuous tabulation
carrying one continuous, one discrete and one mixture distribution and
the correlated law carrying both isotropic and tabulated cosines.

`every_distribution_shape_has_a_fixture` makes that coverage a checked
fact: every shape the dumpers can write must appear in some golden file,
or the test names the one that does not.

25,785 paths across 14 fixtures, up from 25,208. Li6's DLW block matched
bit for bit on the first run, Kalbach-Mann tables included.
`Product` is a secondary particle: its yield, its distributions, and how
it was emitted. `Yield` spans the two forms the format uses — a
polynomial in incident energy, or a tabulation against it.

`radionuclide_production` joins MF=8, MF=9 and MF=10 into what a
consumer wants: for each reaction, the nuclides it leaves behind, in
which state, and how much of each. `isomer_table` and
`level_to_isomeric_state` turn a nuclear level index into an isomeric
state, which is what naming a metastable product needs.

Two real decay evaluations come with them, In116m1 and In116m2, which
between them cover MF=8 MT=457 for the first time — 122 discrete
radiations across seven spectra — and give `isomer_table` something
real to chain: m1 decays only by beta- and so has no measurable
excitation energy, m2 transitions down to m1.

They found a parse bug straight away. A discrete radiation record is
written with NT = 6 or NT = 12 depending on whether the internal
conversion coefficients were evaluated, and both lengths appear inside a
single spectrum. The Rust reader was returning zeros for the absent
coefficients where the Python reader returns nothing — a zero conversion
coefficient is a physical claim, and the wrong one. Now gated on the
record length.

The MF=8/9/10 join is compared against Python for every ENDF fixture,
not only asserted by hand.

26,740 paths across 16 fixtures, up from 25,785.
`Reaction` gathers what an evaluation scatters across its files: the MF=3
cross section and Q values, the MF=4/5/6 distributions of what comes out,
the MF=8/9/10 radioactive products. Plus the reaction name tables —
`reaction_name` and `reaction_mt`, which agree with the Python package's
dictionaries entry for entry across all 412 names and 417 lookups.

The intricate parts are the fission neutrons and the activation
products. Prompt, total and delayed neutrons come from MF=1; each delayed
precursor group's yield is the total scaled by the applicability of its
MF=5 spectrum, on a union grid when that applicability is
energy-dependent. An MF=10 product gives a production cross section
rather than a yield, so the yield is the ratio of two interpolated cross
sections, taken where the reaction actually happens.

One deliberate difference: the Python `Reaction.from_endf` computes the
derived products and then drops them, with a TODO in the source saying
they should be stored. They are stored here. The dumper takes them from
the helper so they are still compared rather than left unchecked.

Coverage. Every reaction of every ENDF fixture is now built and compared
against Python — cross sections, Q values, products, yields,
applicabilities and distributions. U235's fixture is re-trimmed to carry
MF=3 MT=18 and MF=5 MT=455, which brings the six delayed neutron groups
with their spectra, and MF=5 LF=5 with them.

The reaction yields are the second thing compared to a tolerance rather
than exactly, alongside the sampled interpolation: an MF=10 yield divides
two interpolated cross sections, and NumPy's array `log` differs from its
scalar `log` in the last bit. The tolerance is 1e-12 relative, so a real
disagreement still fails.

30,967 paths across 16 fixtures, up from 26,740.
`Reaction::from_ace` reads a reaction out of a processed table: the cross
section from its threshold on the nuclide grid, the multiplicity and the
frame it implies, the DLW linked list of distributions, the angular
distribution from AND, and the photons the reaction produces.

The fission path is its own thing. Prompt and total nu may be given
singly or both together, and which is which depends on whether a delayed
block exists at all. Each delayed group's yield is the total scaled by
its probability, and because the probabilities in an ACE file do not sum
to one, the group yields are renormalised against what they do sum to.

Photon production comes in two forms: a yield taken from ENDF file 12 or
6, or a production cross section from file 13, which becomes a yield once
divided by the reaction's own.

Two places take no Q value where the Python reader passes no reaction —
the delayed spectra and the photon distributions. Law 66 would fail in
both, and does not arise; the comment says so rather than papering over
it with a zero.

Coverage. Every reaction of every ACE fixture is now built and compared
against Python, elastic scattering included: 15 reactions for Li6, with
its photon production attached to the reactions that make it. The
reaction dump is guarded on MTR being present, so the synthetic law table
is left to the DLW walk it exists for.

33,588 paths across 16 fixtures, up from 30,967.
`IncidentNeutron` is the nuclide: its identity, its reactions, its energy
grid at each temperature, and the unresolved resonance tables. From an
ENDF evaluation it is the reactions with an MF=3 cross section; from an
ACE table it is rather more work.

An ACE table gives three summed cross sections outright — the total, the
absorption and the heating number — which become redundant reactions on
the shared grid. It may also assign photon production to an MT with no
cross section of its own, and give a transmutation reaction only as its
separate levels; both cases are filled in by summing the components,
starting at the lowest threshold any of them has. That is what
`threshold_idx` on `Tabulated1D` is for: a processed cross section starts
where its reaction opens, and summing several needs to know where each
began.

`removal_xs` folds the elastic angular distribution into the total, for
point-kernel shielding.

It found issue #21: `AngleDistribution.forward_fraction` fills its result
with `np.empty` and then writes only the Legendre and tabulated entries.
Everything an ACE table produces is neither, so `removal_xs` on ACE data
returns uninitialized memory — the same call gives a different answer
depending on what ran before it. There is no behaviour to match, so the
Rust `forward_fraction` computes the two ACE shapes properly: the
tabulated cosine through its own CDF, the isotropic one as the fraction
of the interval above the cutoff. The removal cross section is compared
against Python on the ENDF path, where the answer is well defined, and
deliberately not on the ACE path, where it is not.

Coverage. Both constructions are compared against Python for every
fixture: the identity, the reaction list, what each redundant reaction is
made of, the energy grid, the redundant flags, and the synthesised
reactions in full.

33,893 paths across 16 fixtures, up from 33,588.
An ENDF evaluation is highly repetitive and compresses about six to one;
the golden dumps, which are as repetitive as the evaluations they come
from, about seven. Together that is 9.4 MB of text down to 1.4 MB, and
95,000 lines of the diff down to sixteen binary blobs.

Python reads them through `endf.fileutils.open_text` and `open_binary`,
which use `lzma` for a `.xz` path and behave exactly as before for
anything else. Compressed files are decompressed whole rather than
streamed: the readers seek back and forth to find material boundaries,
and seeking inside a compressed stream restarts the decoder. `Material`,
`get_materials` and `ace.get_tables` all route through them, so the
support is in the package rather than in the tests.

Rust reads them with `lzma-rs`, which is pure Rust — no C toolchain — and
is a `[dev-dependencies]` entry, so the `endf` crate stays
dependency-free for anything that uses it. The unit tests keep embedding
their fixtures with `include_bytes!` and decompress on use, so they still
need no working directory.

`isomer_table` gains `isomer_table_from_materials`, which is where the
work now happens; the path-taking version is a wrapper. That was needed
to test it without the filesystem, and is a better split anyway.

Everything else is unchanged and verified: the dumps are still
byte-reproducible, 33,893 paths still compare, 103 unit and 5 integration
tests pass in debug and release, and `pytest` gives 168 passed, 2
skipped. Decompression costs the Rust suite about a second in debug.
`Decay` is a radioactive decay evaluation: the nuclide, its half-life,
the average energies that drive decay heat, each decay mode with the
daughter it leaves behind, and the spectrum of each radiation type.
`FissionProductYields` is the independent and cumulative yields from
MT=454 and MT=459.

`sources` turns the spectra into what a source needs: intensities per
decay become rates per second by way of the decay constant, and radiation
types that emit the same particle are combined — a nuclide emitting both
gammas and x-rays gives one photon distribution. That needed
`combine_distributions` in `univariate`, which merges the discrete parts
and mixes the rest.

The decay mode encoding is the fiddly part. RTYP packs a chain of modes
as the digits of a decimal — 1.5 is a beta- followed by a neutron — and
the Python reader decodes it by formatting the float and stripping zeros
and the point. That only works because Python's `str` always writes a
fractional part, so `10.0` keeps its trailing zero where Rust's shortest
round-trip format would give `10` and lose it. `python_float_str` puts
the `.0` back, and the tests pin both the ordinary cases and the two the
encoding cannot express.

Coverage. The whole of `Decay`, including `sources`, is compared against
Python for both In116 decay fixtures — 488 paths each, every discrete
line of all seven spectra. 34,512 paths across 16 fixtures.
`IncidentPhoton` is the photon interaction data of one element:
`PhotonReaction` per channel, with the MF=27 form factors and anomalous
scattering terms attached to the MF=23 reactions they belong to, and
`AtomicRelaxation` for how the atom fills a vacancy afterwards. Both
sources are covered — the photoatomic sublibrary, and a processed ACE
photoatomic table, whose energy grid and cross sections are stored as
logarithms and whose transition probabilities are cumulative.

`compton_profile_cdfs` and `compton_subshell_map` come too. The second is
the awkward one: it walks the two orderings in step and stops at the
first occupancy that cannot be made from whole subshells, because past
that point the orderings have diverged and any pairing would be invented.

One thing is deliberately not ported. `_add_compton_profiles` and
`_add_bremsstrahlung` attach data from an HDF5 file shipped with the
package, resampled with a cubic spline — auxiliary data rather than
evaluated data, and it would cost the crate an HDF5 reader and a spline
implementation to carry. An ACE table's own Compton profiles *are* read,
since those come from the file. The gap is stated in the crate docs.

Coverage. The photon path is compared against Python for all three
atomic fixtures — reactions, form factors, binding energies,
fluorescence yields, sum-rule components and the relaxation transitions.
34,669 paths across 16 fixtures.
`Chain::from_endf` is the join of three sub-libraries: what a nuclide
decays into, what a neutron turns it into, and what its fission leaves
behind. With it come `REACTIONS` — all 84 transmutation reactions, the
MTs that mean each and what they do to A and Z — `Nuclide` and its decay
and reaction paths, `normalise_branch_ratios`, the two stand-in searches,
`branch_ratios`/`set_branch_ratios`, and `reduce`.

Two things are deliberately absent. The XML serialisation needs an XML
writer, and `form_matrix` needs sparse linear algebra; neither reads a
nuclear data format, and both belong in a consumer. The module docs say
so.

Branch normalisation is worth a line: the residual goes into the largest
branch, because that is the one it perturbs fractionally least. Dropping
it into an arbitrary branch can move a 1e-9 branch by orders of
magnitude, and the test pins that.

It found issue #22. `replace_missing` steps the atomic number towards
stability until it finds a nuclide the library has, with nothing bounding
the walk. Given a decay library that is a subset — a reduced chain, a
test fixture, a library missing an element — it runs Z down past one and
dies on `ATOMIC_SYMBOL[-1]`. A full sub-library always terminates it,
which is why it survives. The Rust version stops and returns None, and
its caller drops the target rather than inventing one.

Coverage. Ten decay evaluations, chosen to close every path the chain
follows except Cs137's — barium is left out so the stand-in walk runs —
plus two neutron evaluations for the Q values. The whole chain is
compared against Python: 63 paths over ten nuclides, their half-lives,
decay energies, normalised branching ratios and reaction Q values.

The golden harness grows a `KIND chain` form for it, since a chain has
several sources rather than one. 34,732 paths across 17 goldens.
`njoy` composes the NJOY input deck and runs it: `ace_deck` builds the
deck, `run` stages the tapes and drives the process, `make_ace`
concatenates the per-temperature ACE files afterwards. Composing is
separated from running so a deck can be inspected, written out and tested
without NJOY installed — which is how it is tested here.

The deck is held to Python's byte for byte. `tests/reference/` gains the
deck the Python `make_ace` composes for Am244 at two temperatures,
captured with its `run` stubbed out, and a unit test compares the Rust
one against it. That caught the same Python-float trap the decay mode
encoding hit: 900 K has to be written `900.0`, not `900`. The formatter
that fixes both now lives in `data` as `python_float_str` with a test of
its own, since it is a Python convention two unrelated modules depend on.

The temporary directory is hand-rolled, because the crate has no
dependencies and needs none for this.

`Chain::validate` and `Nuclide::validate` come with it. They return what
does not add up rather than raising on the first thing, warning, or
returning a bare bool depending on three flags — a caller can then report
all of it, or ignore it.

129 unit tests, 5 integration, debug and release. The reference deck sits
in `tests/reference/` rather than `tests/golden/` so the golden harness
does not try to parse it as a dump; that directory has a README saying
what it is and how to regenerate it.
The decay fixtures added with the chain caught this. ENDF/B-VIII.0's
Xe136 is flagged unstable and given a half-life of zero — the evaluation
declining to state it, its real one being some 10^21 years. The Python
`decay_constant` divides by it and raises ZeroDivisionError, taking
`sources` with it; filed as issue #23. The package already reads zero the
right way elsewhere: `Chain.from_endf` skips a nuclide's decay modes when
the half-life is zero.

So `decay_constant` returns None there, and `sources` an empty map. An
infinite decay constant is not a fact about the nuclide.

That also fixes a golden I had committed truncated: the generator died
part-way through Xe136 and I had redirected its error away. Xe136's dump
is now complete, and the generator's failure would now be a failure.

Propagated uncertainties join the sampled interpolation as compared to a
tolerance rather than exactly. `uncertainties` accumulates a variance and
takes its square root where the port applies the derivative directly, so
the two differ in the last bit or two. The nominal values still compare
exactly, which is the part that carries the physics.

25 goldens, 37,618 paths.
The bindings were four types and three functions. They now expose the
layer a consumer actually reads: `Material`, `Tabulated1D`,
`CrossSection`, `Product`, `Reaction`, `IncidentNeutron`,
`IncidentPhoton`, `Decay`, `Chain` and `AceTable`, with `get_materials`,
`get_tables`, `reaction_name`, `reaction_mt` and `gnds_name` beside them.

The sum types come across as dicts tagged with a `kind` key rather than
as a class per variant — an angle-energy distribution, an outgoing energy
law, a univariate density. That is the shape a consumer wants anyway:
`kind` is exactly the discriminant an Arrow union column needs, and the
alternative is a dozen wrapper classes that express nothing more.

It is still not a drop-in for `Material.section_data`, which returns the
Python reader's own dictionaries keyed by ENDF field name — one shape per
MF. What is exposed is the typed layer above that. The README says so
rather than leaving it to be discovered.

`endf-py` gains `lzma-rs` so a `.xz` path works there as it does in
`endf.fileutils.open_text`; a path that reads in one reader ought to read
in the other. The `endf` crate itself stays dependency-free.

Tested by comparison, not by assertion of remembered values.
`tests/test_rust_bindings.py` runs the extension and the pure-Python
reader over the same fixtures and compares: 30 tests over records,
materials, cross sections, every reaction of Am244, ACE tables and
nuclides, photon data with its atomic relaxation, decay with its source
distributions, and a whole depletion chain. It skips itself when the
module is not built.

CI in `.github/workflows/rust.yml`, three jobs: the crate (fmt, clippy
with -D warnings, test in debug and release); the goldens, which must
still regenerate byte-identical, since a change that quietly rewrites
them would defeat the harness; and the bindings, built as an abi3 wheel
with maturin on 3.10 and 3.13 and then tested, importing `_endf`
explicitly first so a build that did not take fails rather than skips.
`local-develop` fixes reading an ACE file that contains a value NJOY
wrote without its `e` — a three-digit exponent overflows the field, so
`6.10562372605e-318` goes out as `6.10562372605-318`. numpy 2 raises
where numpy 1 returned a short array, so the recovery never ran (#20).

The Rust reader already put the `e` back and got the right answer, but
nothing held it to that on a whole file. `tests/synthetic-denormal.ace`
now does: eight XSS values, four of them denormal, including a negative
one and one that rounds to the nearest subnormal, with ordinary values
between them so a reader that mangles the array rather than the token is
caught too. Both readers agree on all nine.

The exact tokens from the report are in a unit test as well, since a
fixture says which file broke and a unit test says which token.
@shimwell
shimwell force-pushed the claude/rust-rewrite-python-layer-1upmmo branch from daf1081 to 054141e Compare August 9, 2026 07:23
claude and others added 15 commits August 9, 2026 07:35
`Material.section_data` and `material[3, 1]` now hand back the same
dictionaries the Python reader does, keyed by the same ENDF field names.
93% of the sections across the fixtures have one — MF 1, 3, 4, 5, 8, 9,
10, 12, 13, 14, 15, 23, 27 and 28.

Verified by comparison, not by inspection. `test_section_data_matches`
walks both readers' dictionaries recursively over all 21 ENDF fixtures
and compares every leaf, so a renamed key, a missing one or a wrong value
fails. Writing it caught three: `E_int` is a `Tabulated2D` and not a
dict, MF=15 subsections carry `NE`, and MF=14's angular tables are `p_k`.
`Tabulated2D` is now a class in the extension for the same reason.

A section with no dictionary form is left out of `section_data` rather
than half-built, and asking for it by key says so and says why. That set
is pinned in `SECTIONS_WITHOUT_A_DICT` and asserted, the same way the
crate pins its uncovered parsers, so it cannot shrink or grow quietly.
MF 2, 6, 7, 26, 33 and 34 are simply not written yet; MF=8 MT=457 is
absent on purpose, since decay data is better reached through `Decay`.

pytest is 221 passed, 2 skipped.
MF 33, 34 and 40 were the last three files in the fixtures whose sections
came back missing from section_data. They now have dictionaries matching
the Python reader's, with the NC/NI subsection split branching on LTY and
LB the same way the golden dumper does.

MF 34 stays empty, as it is upstream (issue #18), and MF 40 reuses the
MF 33 subsection projection for its sub-subsections.

That takes the fixtures from 372/400 sections to 375/400. The pinned set
in SECTIONS_WITHOUT_A_DICT loses (33, 103), (33, 105) and (34, 51), which
is what the assertion is for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
Six more projections, leaving MF=2 MT=151 as the only section in the
fixtures without a dictionary form.

MF=6 and MF=26 share LAW=1 and LAW=2, so the two law projections are
shared functions rather than written twice. Both files leave the
distribution key out entirely for a law that carries no data — LAW<0, 0,
3 and 4 in MF=6, an unrecognised law in MF=26 — as Python does.

The upstream quirks are reproduced with them: MT=458 reports ZA as a
float because it is read from a CONT record (issue #14), and MF=7 MT=4
stores the outer LT on each additional temperature rather than the LI it
read (issue #16). NFC, beta_int/NB/beta_data and the coherent/incoherent
keys are conditional in the same places.

Fixture coverage goes from 375/400 sections to 386/400.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
Every section across the fixtures now has a dictionary form — 400 of 400
— so code written against Material.section_data runs unchanged against
either reader, and SECTIONS_WITHOUT_A_DICT is empty. The assertion stays,
now guarding against a projection disappearing rather than tracking ones
not yet written.

MF=2 covers all four representations the reader parses: the bare
scattering radius, Breit-Wigner, Reich-Moore and R-matrix limited, plus
the unresolved region's three cases. Which unresolved case applies is
decided from LFW and LRF rather than from what the struct happens to
hold, so a range with no J values keeps the shape its case calls for.
An unresolved range with LRF=1 is dispatched past unread upstream
(issue #15) and so keeps only its own keys here.

MF=8 MT=457 was left out on the argument that Decay is a better shape.
It still is, but that is a reason to prefer it, not a reason for the
dictionary to be missing. A stable nuclide stops after spin and parity,
and a discrete record too short for its internal conversion coefficients
gets an empty tuple rather than a zero, matching the slicing upstream.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
Material.interpret() was the one method on the Python Material with no
counterpart here. It is added to the crate rather than to the bindings,
as an Interpretation enum, so a Rust consumer gets it too — NSUB=10
builds an IncidentNeutron, NSUB=3 an IncidentPhoton, anything else is an
error. Thermal scattering, NSUB=12, is the case that has no class in
either reader.

Alongside it, the module-level surface the Python package exposes and
the extension did not: zam, temperature_str, photon_reaction_name and
photon_reaction_mt, decay_modes, normalise_branch_ratios, and the
constant tables ATOMIC_SYMBOL, SUM_RULES, INTERPOLATION_SCHEME,
FISSION_MTS, EV_PER_MEV and K_BOLTZMANN. Every one already existed in
the crate; what was missing was the boundary.

The tables are built at import rather than stored, because the crate
holds them as arrays and the Python package as dictionaries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
The depletion-facing part of the API: radionuclide_production, which
joins MF=8's identification of a radioactive product to the MF=9 yield
and MF=10 production cross section, plus isomer_table and
level_to_isomeric_state, which are what turn an MF=8 level index into a
named metastable nuclide.

RadionuclideProduction is a class rather than a dict, following the rule
the rest of the surface uses: it is a concrete type, not a sum type.

isomer_table reads through the same path as everything else here rather
than through endf::isomer_table, so a compressed evaluation works. Its
result is a plain nested dict, matching upstream, and
level_to_isomeric_state takes that dict straight back — so a table built
by either reader can be passed to either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
MF=8 MT=454 and MT=459 were the last two parsers with no fixture. A real
yield evaluation is megabytes of products and cannot be trimmed — the
yields are the file — so tools/make_nfy_endf.py writes a small one
instead, the way the synthetic ACE fixtures were made.

The fixture is built to catch the mistakes that matter: independent and
cumulative yields differ, so returning one for the other fails; the fast
energy carries one product more than the thermal one, so reusing NFP
across energies fails; and a product isomer exercises the naming path.
The golden harness picks it up on its own and now holds both readers to
403 sections rather than 400.

With a fixture to test against: section_data gains its MF=8 MT=454/459
projection, including the L1 field the format overloads — LE+1 at the
first energy, the interpolation scheme after — which upstream keys
differently in the two cases.

FissionProductYields gains the nuclide it was missing, read from MF=1
MT=451 as upstream reads it, and is exposed to Python. Its yields come
across as {name: (value, uncertainty)} rather than as ufloats, which is
the same pair the Python objects carry once taken apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
**The wheel had the wrong name.** `maturin build -m crates/endf-py/Cargo.toml`
run from the repository root falls back to the pyproject.toml in the working
directory, which is the pure Python package's, so the wheel came out called
`endf` — shadowing the very package it is meant to sit beside — and
`pip install endf-py` then found nothing. `crates/endf-py/pyproject.toml`
makes the build independent of where it is run from. Reproduced with the
exact CI invocation before and after.

**The golden drift check was testing the wrong thing.** It regenerated the
files and asked git whether the bytes changed. Two problems: the goldens are
xz-compressed, and two encoders can write identical content differently, so
the check could fail for a reason that says nothing about the reader; and
when it did fail all it could report was `Bin 14796 -> 14796 bytes`.

`--check` now compares the dump text and prints the offending lines, so CI
says which value moved. That immediately showed the real failure: NumPy
re-associated the Clenshaw recurrence between 2.2 and 2.4, so
`forward_fraction` — a Legendre antiderivative — and the `removal_xs` that
folds it in differ in the last bit or two depending on which NumPy wrote the
golden. Both now sit in the tolerance category alongside the interpolation
samples and the propagated uncertainties, on both sides of the harness.

Verified by regenerating every golden under NumPy 2.2 and running the Rust
parity tests against them: they pass, where before the change they would not
have. `--check` agrees on Python 3.10, 3.11, 3.12 and 3.13, spanning NumPy
2.2.6 to 2.5.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
Four parsers were structurally complete and had never been run against a
file, because no evaluation small enough to keep here writes them.
tools/make_shapes_endf.py writes one that does, on top of a new
tools/endf_writer.py that make_nfy_endf.py now shares — verified by
checking that the NFY fixture comes out byte-identical after the move.

Each shape is built around the mistake it is meant to catch: the
Breit-Wigner section has two L values with different resonance counts and
a non-zero QX/LRX pair; LF=12 keeps EFL and EFH on the record that
introduces the subsection rather than the one after; LAW=1 with LANG=2
has NA=1, so the row stride is three rather than two; LAW=6 is the one
body in MF=6 that is a bare CONT; and MF=13 has NK=2, so the total
production record is present, which is the branch a reader drops.

That leaves MF40 as the only parser no fixture reaches, and
UNCOVERED_BY_ANY_FIXTURE says so. Fixtures go from 22 to 23 and compared
sections from 403 to 414, all of which the Rust reader matches.

Writing the fixture found two bugs in the harness itself, both only
reachable with data like this:

  - the dumper read `MadlandNix.t_m`, which is spelled `tm`, so LF=12
    could never have been dumped;
  - it wrote the golden as it went, so that failure left a truncated file
    behind, and a truncated golden reports every path after the failure
    as missing and buries the real error. The dump is now built whole in
    memory and written once complete.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
Groundwork for yamc depending on `endf`, without moving anything yet.
Each of these is a thing that would have bitten a consumer.

**The ACE types were not re-exported.** `IncidentNeutron::from_ace` takes
a `MetastableScheme`, which lived behind `endf::ace::` — so calling a
root-level method required a module path. `Table`, `TableType`,
`get_table`, `get_tables` and `tables_from_str` were in the same
position, and ACE is where a transport code starts. All now at the root.

**Nothing verified the declared MSRV.** The crate says
`rust-version = "1.74"` and consumers will believe it, but every CI job
ran stable, which happily accepts APIs stabilised years later — this
port used `is_none_or` (1.82) at one point and it was caught by reading,
not by testing. A job now builds the library on 1.74. The library only:
dev-dependencies reach nobody downstream and are free to need newer.

**A publish would have shipped the harness without its fixtures.**
`cargo package` included 900 KB of golden dumps, while the evaluations
they name live in `tests/` at the repository root, outside the crate —
so the tests could not have run from the package regardless. Excluded;
the package is 116 KB. The parity harness is a repository concern, which
is also the reason the crate is easier to depend on than to move.

**tests/public_api.rs** walks a consumer's path — file to nuclide to
cross section, ACE table to nuclide, the depletion inputs, a chain —
importing only from `endf::` rather than from module paths. It is a
shape test, not a value test; the goldens check the numbers. It caught
the ACE re-exports above, and it pins that an ENDF evaluation yields no
unionised energy grid while an ACE table does, which is the sort of
thing a consumer otherwise discovers at runtime.

Also a crate-level README with metadata for the publish path. Its two
examples were compiled against the real API rather than written from
memory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
There were none, of any kind — no `.pyi`, no `py.typed`. `_endf` is a
compiled module, so an editor and a type checker saw nothing whatsoever
in it. That costs more here than for most extensions: the module is
meant to be substitutable for the pure-Python `endf` package, and
swapping one for the other should not cost you every type you had.

`crates/endf-py/_endf.pyi` covers the whole surface — 13 classes with 95
members, 17 functions, 6 constant tables. maturin turns the file into a
PEP 561 package on its own, so the wheel now installs `_endf/py.typed`
and `_endf/__init__.pyi` beside the extension with no packaging changes
beyond the file existing.

A hand-written stub for a compiled module rots silently, because nothing
imports it, so two things hold it in place:

  - `tests/test_rust_stub.py` parses the stub and compares its declared
    names against the built module in both directions, then checks the
    installed stub is the one in the tree. Verified by deleting a method
    from the stub and confirming the failure names it.
  - `crates/endf-py/typecheck.py` is ordinary use of the module with
    every result bound to an explicit annotation, run under
    `mypy --strict` in CI. Names matching is not types matching, and
    only a type checker catches a getter declared with the wrong one.

Both run in the bindings job. Confirmed the stub does its job by
type-checking three deliberate errors — an int assigned to a str, a
method that does not exist, and a str passed where a float is wanted —
and seeing all three reported.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
Cross-checking against OpenMC, ENDFtk and endf-parserpy settled which of
the issues raised while porting are genuinely wrong rather than merely
unusual. These five are, and each is now fixed on both sides with a test
that fails if it comes back.

**#15, unresolved ranges with LRF=1.** LRU says resolved or unresolved;
LRF only selects the formalism within a range. Both readers tested LRF,
so a range with LRU=2 and LRF=1 -- Cases A and B, how most actinides
write their unresolved region -- matched no branch. Proven with a built
file: the parameters were dropped *and* the records left unread, so the
following range came back as LRU=0 with its EL and EH holding the
previous range's SPI and AP. tests/synthetic-urr-cases.endf.xz now
carries Case A, Case B and a resolved range behind Case A, so the
alignment is checked and not just the parse.

**#23, zero half-life.** Zero means "not evaluated", not "decays
instantly". `decay_constant` divided by it and raised ZeroDivisionError
from inside the expression. It now returns None and `sources` returns
{}, which is what `Chain.from_endf` already assumed for these nuclides.
OpenMC guards the same case; that guard was lost in the port.

**#12, MF=33 NC subsections.** The LTY=0 branch appended inside the `if`
and again after it, so those subsections appeared twice.

**#18, MF=34.** Every subsection was parsed and then dropped, because
nothing appended it -- `section_data[34, 51]['subsections']` was always
empty. And LB was filled with LS. U235 shows why that matters: LB is 5
throughout while LS is 1, 0, 1, so the blocks were reported as three
different matrix types when all three are covariance matrices.

**#19, ACE law 5.** Not implemented in any of the three readers, but the
dispatch reached a `from_ace` that did not exist and died with
AttributeError. Now raises NotImplementedError, as OpenMC does.

The goldens move where the fixes change what is read: U235 gains 27 MF=34
paths that were previously absent on both sides, and Xe136's empty decay
constant is now compared rather than skipped by both dumpers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
The ENDF photon files were already ported -- MF 23, 26, 27, 28 and the
photon production files, plus ACE photoatomic tables. What was missing
was the two things `IncidentPhoton` carries that no evaluation contains:
Compton profiles and bremsstrahlung. Both are looked up by atomic number
in tabulations that ship beside the package.

Two obstacles, both now gone.

**HDF5.** `compton_profiles.h5` and `density_effect.h5` cannot be read by
a crate with no dependencies. `tools/convert_photon_data.py` rewrites
them as one plain text file, checked bit-exact against every value in
both. 359 kB replaces 805 kB, and it is left uncompressed on purpose:
this is read at runtime, so xz would put a decompressor in the crate's
real dependencies rather than its dev-dependencies. `BREMX.DAT` needed
nothing, being text already.

**The spline.** The bremsstrahlung cross sections are tabulated on 57
electron energies and resampled onto 200. SciPy's `CubicSpline` defaults
to **not-a-knot**, not natural, and getting that wrong would bend every
element's cross sections near the ends of the grid. `spline.rs` is a
transcription of what SciPy does, including the order of the arithmetic.

Neither file is embedded. Together they are 2.5 MB, which does not belong
in every binary that links this crate, and a consumer reading nuclear
data is opening files anyway. `PhotonData::from_files` reads them and
`IncidentPhoton::add_photon_data` attaches them by atomic number, which
is the one visible difference from the Python package -- there the lookup
happens inside `from_endf`, because the package can find its own data
directory and a crate cannot.

Verified by dumping every value from both readers and comparing:

  - 49,830 Compton profile, binding energy and density-effect values:
    bit-identical
  - 600,000 resampled bremsstrahlung cross sections, through two
    independent spline implementations: worst relative difference
    3.8e-15

SciPy stays on the Python side deliberately, so the two implementations
keep checking each other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
`photon_aux.txt` was built from the two HDF5 files this package inherited
from OpenMC. Half of it no longer is: `tools/make_photon_aux.py` downloads
Geant4's G4EMLOW data set, verifies it against a pinned SHA-256, and reads
`doppler/p-biggs.dat`, `doppler/profile-<Z>.dat` and
`doppler/shell-doppler.dat` directly. That archive is the distribution of
the Biggs, Mendelsohn and Mann tables, and its own README says so, so the
provenance is first-hand and the build is reproducible from it.

The result is **bit-identical to the HDF5 it replaces**, for all 100
elements: same pz grid, same J, same occupancies, same binding energies.
So this changes where the data comes from and nothing about what it is.

G4EMLOW 6.48 is pinned rather than the newest release. Every file in the
`doppler` directory is byte-identical from 6.48 through 8.7 — compared
file by file rather than assumed — so the newer archives carry the same
Compton data at 333 MB instead of 24 MB. The pin is recorded with the
reason, so it can be revisited if that ever stops being true.

The density effect half is still vendored from `density_effect.h5`. NIST
ESTAR publishes those mean excitation energies through a web form rather
than as a download, so there is no primary source to fetch; the script
says so at the one function that would have to change.

The generated file now carries a header naming both sources, and the Rust
parser skips comment lines so it can.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
photon_aux.txt held two unrelated measurements: the Biggs Compton
profiles, which now come from Geant4 and can be verified against a pinned
archive, and the ESTAR density effect, which is vendored because NIST
publishes it through a web form. They only ever shared a file because
they shared an HDF5 container, and they have different provenance and
different regeneration lifecycles. Splitting them lets each say what it
is and where it came from in its own header.

  compton_profiles_biggs1975.txt      fetched from G4EMLOW, verified
  density_effect_sternheimer1982.txt  vendored from density_effect.h5

PhotonData::from_files takes one path per source rather than one per
format. The crate never looks a file up by name, so this is the only
coupling between it and whatever a consumer calls these on disk, and one
argument per source is the shape that lets a consumer name them freely.

BREMX.DAT keeps its name. endf.incident_photon reads it by that name, and
this branch does not change the Python reader.

No values move: the two files concatenate back to the byte content of the
one they replace, headers aside.
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.

2 participants