Skip to main content

nereids_endf/
parser.rs

1//! ENDF-6 File 2 resonance parameter parser.
2//!
3//! Parses the fixed-width 80-character ENDF-6 format to extract resolved
4//! resonance region (RRR) parameters.
5//!
6//! ## ENDF-6 Line Format
7//! Each line is exactly 80 characters:
8//! - Cols 1-11:  Field 1 (floating point or integer)
9//! - Cols 12-22: Field 2
10//! - Cols 23-33: Field 3
11//! - Cols 34-44: Field 4
12//! - Cols 45-55: Field 5
13//! - Cols 56-66: Field 6
14//! - Cols 67-70: MAT number
15//! - Cols 71-72: MF (file number)
16//! - Cols 73-75: MT (section number)
17//! - Cols 76-80: Line sequence
18//!
19//! ## SAMMY Reference
20//! - SAMMY manual Section 9 (ENDF-6 format)
21//! - SAMMY source: `sammy/src/endf/` module
22
23use crate::resonance::*;
24use nereids_core::elements::isotope_from_za;
25
26/// ENDF radius unit conversion factor.
27///
28/// ENDF-6 Formats Manual §2.1: "AP, APE, APT are in units of 10⁻¹² cm."
29/// Physics convention: 1 fm = 10⁻¹³ cm, so 10⁻¹² cm = 10 fm.
30/// All ENDF radii (AP, APL, APE, APT, AP(E) tables) are multiplied by this
31/// factor at parse time so that downstream physics uses true femtometers.
32///
33/// SAMMY applies the identical ×10 conversion when reading ENDF:
34///   `FillSammyRmatrixFromRMat.cpp` line 422: `newChannel->getApe() * 10.0`
35const ENDF_RADIUS_TO_FM: f64 = 10.0;
36
37/// Parse ENDF-6 File 2 resonance parameters from raw ENDF text.
38///
39/// Extracts all MF=2, MT=151 lines and parses the resolved resonance region.
40///
41/// # Arguments
42/// * `endf_text` — Full ENDF file contents as a string.
43///
44/// # Returns
45/// `ResonanceData` containing all parsed resonance parameters.
46pub fn parse_endf_file2(endf_text: &str) -> Result<ResonanceData, EndfParseError> {
47    // Extract MF=2, MT=151 lines (resonance parameters).
48    let lines: Vec<&str> = endf_text
49        .lines()
50        .filter(|line| {
51            if line.len() < 75 {
52                return false;
53            }
54            let mf = line[70..72].trim();
55            let mt = line[72..75].trim();
56            mf == "2" && mt == "151"
57        })
58        .collect();
59
60    if lines.is_empty() {
61        return Err(EndfParseError::MissingSection(
62            "No MF=2, MT=151 data found".to_string(),
63        ));
64    }
65
66    let mut pos = 0;
67
68    // HEAD record: ZA, AWR, 0, 0, NIS, 0
69    let head = parse_cont(&lines, &mut pos)?;
70    let za = head.c1 as u32;
71    let awr = head.c2;
72    let nis = checked_count(head.n1, "NIS")?; // number of isotopes (usually 1)
73
74    // ENDF-6 §2.1 requires NIS >= 1 for a valid resonance evaluation. NIS=0
75    // would leave the parser with no isotope subsection to read and fall
76    // through to a confusing "unconsumed data lines" downstream failure;
77    // reject up-front with a clear message.
78    if nis == 0 {
79        return Err(EndfParseError::UnsupportedFormat(
80            "MF=2 NIS=0: no isotopes declared. ENDF-6 §2.1 requires NIS >= 1 \
81             for a valid resonance evaluation."
82                .into(),
83        ));
84    }
85    // ENDF-6 §2.1: a material with NIS>1 contains multiple isotope subsections,
86    // each carrying its own ZAI, ABN, LFW, and NER ranges (e.g. natural-element
87    // evaluations such as nat-C with ZAI={6012,6013}). The reference reader
88    // OpenScale (File2.cpp:71-87) stores these in a Vec<ResonanceIsotope>
89    // tagged with per-isotope ABN, and downstream physics combines the per-
90    // isotope cross sections with that ABN weighting. NEREIDS's `ResonanceData`
91    // has no per-isotope discriminator and no abundance field; silently
92    // flattening multi-isotope subsections into one flat range list would
93    // discard ZAI/ABN and produce abundance-blind cross sections. Reject
94    // the case explicitly until a proper multi-isotope container is wired
95    // through. Single-isotope (NIS=1) ENDF evaluations remain fully supported.
96    if nis > 1 {
97        return Err(EndfParseError::UnsupportedFormat(format!(
98            "MF=2 NIS={nis} > 1 multi-isotope materials are not supported. \
99             Each NEREIDS ResonanceData represents a single isotope. \
100             For multi-isotope ENDF evaluations, split the material into per-isotope \
101             files or use sammy_to_resonance_data_multi from nereids_endf::sammy."
102        )));
103    }
104
105    let isotope = isotope_from_za(za)?;
106    let mut all_ranges = Vec::new();
107
108    for _ in 0..nis {
109        // Isotope CONT: ZAI, ABN, 0, LFW, NER, 0
110        let iso_cont = parse_cont(&lines, &mut pos)?;
111        let _zai = iso_cont.c1 as u32;
112        let _abn = iso_cont.c2; // abundance
113        let lfw = iso_cont.l2; // fission width flag (LFW=1 → energy-dependent fission widths in URR)
114        let ner = checked_count(iso_cont.n1, "NER")?; // number of energy ranges
115
116        for _ in 0..ner {
117            // Range CONT: EL, EH, LRU, LRF, NRO, NAPS
118            let range_cont = parse_cont(&lines, &mut pos)?;
119            let energy_low = range_cont.c1;
120            let energy_high = range_cont.c2;
121            let lru = range_cont.l1; // 1=resolved, 2=unresolved
122            let lrf = range_cont.l2; // resonance formalism
123
124            if lru == 2 {
125                // Unresolved resonance region (LRU=2): parsed and SKIPPED.
126                //
127                // NEREIDS does not compute URR average cross sections — the
128                // Hauser-Feshbach path was removed (it lacked the ENDF
129                // width-fluctuation correction, a systematically wrong average).
130                // We still consume the URR body to keep the line stream aligned
131                // so resolved ranges in the same evaluation remain accessible,
132                // and tag the range Unresolved (non-evaluable → Skip in the
133                // physics layer). Structural guards inside the skip helpers
134                // detect malformed records that would otherwise misalign the
135                // cursor. Reference: ENDF-6 §2.2.2.
136                let nro_urr = range_cont.n1; // energy-dependent scattering radius flag
137                let naps_urr = range_cont.n2; // scattering radius calculation flag
138                // A TAB1 AP(E) record precedes the URR body when NRO != 0.
139                // Store it (converted to fm) on the placeholder, mirroring the
140                // LRF=7 skip: the placeholder preserves everything the header
141                // carries even though the range is never evaluated.
142                let ap_table_urr = if nro_urr != 0 {
143                    let mut tab = parse_tab1(&lines, &mut pos)?;
144                    for pt in &mut tab.points {
145                        pt.1 *= ENDF_RADIUS_TO_FM;
146                    }
147                    Some(tab)
148                } else {
149                    None
150                };
151
152                // LRF must be 1 or 2 for the URR (ENDF-6 §2.2.2). Any other
153                // value is a malformed URR record; reject it as an unsupported
154                // format — consistent with the sibling malformed-record guards
155                // (INT range, N1 relations) — rather than heuristically
156                // consuming a guessed body and silently dropping the span.
157                if lrf != 1 && lrf != 2 {
158                    return Err(EndfParseError::UnsupportedFormat(format!(
159                        "LRU=2 (URR) with LRF={lrf}: ENDF-6 §2.2.2 restricts \
160                         the unresolved region to LRF=1 or LRF=2"
161                    )));
162                }
163
164                // LFW=1/LRF=1 uses the "Case B" shared-energy-grid layout
165                // (ENDF-6 §2.2.2.1); LFW=1/LRF=2 is byte-identical to LFW=0/LRF=2.
166                let (spi_urr, ap_urr_fm) = if lfw != 0 && lrf == 1 {
167                    skip_urr_lfw1_lrf1(&lines, &mut pos)?
168                } else {
169                    skip_urr_range(&lines, &mut pos, lrf)?
170                };
171
172                all_ranges.push(ResonanceRange {
173                    energy_low,
174                    energy_high,
175                    resolved: false,
176                    formalism: ResonanceFormalism::Unresolved,
177                    target_spin: spi_urr,
178                    scattering_radius: ap_urr_fm,
179                    naps: naps_urr,
180                    ap_table: ap_table_urr,
181                    l_groups: Vec::new(),
182                    r_external: vec![],
183                });
184                continue;
185            }
186
187            if lru == 0 {
188                // LRU=0: scattering-radius-only range (no resonance parameters).
189                // ENDF-6 §2.1: after the range CONT (and optional TAB1 if
190                // NRO!=0), a single CONT record follows: [SPI, AP, 0, 0,
191                // NLS=0, 0]. We consume the NRO TAB1 if present, then the CONT,
192                // and store a non-evaluable placeholder (rather than dropping
193                // the range) so the LRU=0 span is named by skip_description and
194                // the no-evaluable-content error — a file whose only range is
195                // LRU=0 is still rejected (NEREIDS cannot evaluate a
196                // resonance-parameter-free stanza, and loading it would yield
197                // silent zero cross-sections), but with an accurate message.
198                let nro_lru0 = range_cont.n1;
199                let naps_lru0 = range_cont.n2;
200                let ap_table_lru0 = if nro_lru0 != 0 {
201                    // TAB1 AP(E) precedes the SPI/AP CONT; consume and store it.
202                    let mut tab = parse_tab1(&lines, &mut pos)?;
203                    for pt in &mut tab.points {
204                        pt.1 *= ENDF_RADIUS_TO_FM;
205                    }
206                    Some(tab)
207                } else {
208                    None
209                };
210                // CONT: SPI, AP, 0, 0, NLS=0, 0
211                // Validate NLS=0 (#123): a non-zero NLS in an LRU=0 range is
212                // malformed and would cause the parser to look for L-groups that
213                // don't exist, misaligning the cursor for subsequent ranges.
214                let spi_cont = parse_cont(&lines, &mut pos)?;
215                // ENDF-6 §2.1: the SPI/AP CONT is [SPI, AP, 0, 0, NLS=0, 0].
216                // Validate that L1 and L2 are both zero — non-zero values
217                // indicate a malformed or mis-identified record.
218                if spi_cont.l1 != 0 {
219                    return Err(EndfParseError::UnsupportedFormat(format!(
220                        "LRU=0 range: L1={} in SPI/AP CONT record must be 0",
221                        spi_cont.l1
222                    )));
223                }
224                if spi_cont.l2 != 0 {
225                    return Err(EndfParseError::UnsupportedFormat(format!(
226                        "LRU=0 range: L2={} in SPI/AP CONT record must be 0",
227                        spi_cont.l2
228                    )));
229                }
230                if spi_cont.n1 != 0 {
231                    return Err(EndfParseError::UnsupportedFormat(format!(
232                        "LRU=0 range: NLS={} in SPI/AP CONT record must be 0 \
233                         (scattering-radius-only ranges have no L-groups)",
234                        spi_cont.n1
235                    )));
236                }
237                if spi_cont.n2 != 0 {
238                    return Err(EndfParseError::UnsupportedFormat(format!(
239                        "LRU=0 range: N2={} in SPI/AP CONT record must be 0",
240                        spi_cont.n2
241                    )));
242                }
243                all_ranges.push(ResonanceRange {
244                    energy_low,
245                    energy_high,
246                    resolved: false,
247                    formalism: ResonanceFormalism::ScatteringRadiusOnly,
248                    target_spin: spi_cont.c1,
249                    scattering_radius: spi_cont.c2 * ENDF_RADIUS_TO_FM,
250                    naps: naps_lru0,
251                    ap_table: ap_table_lru0,
252                    l_groups: Vec::new(),
253                    r_external: vec![],
254                });
255                continue;
256            }
257
258            if lru != 1 {
259                return Err(EndfParseError::UnsupportedFormat(format!(
260                    "LRU={} not supported (expected 0=scattering-radius-only, 1=resolved, or 2=unresolved)",
261                    lru
262                )));
263            }
264
265            let nro = range_cont.n1; // energy-dependent scattering radius flag
266            let naps = range_cont.n2; // scattering radius calculation flag
267
268            // If NRO != 0, a TAB1 record immediately follows giving AP(E).
269            // Parse and store it; scattering_radius_at(E) will interpolate it
270            // at each energy point.  Reference: ENDF-6 §2.2.1; SAMMY mlb/mmlb1.f90.
271            let ap_table = if nro != 0 {
272                let mut tab = parse_tab1(&lines, &mut pos)?;
273                // AP(E) y-values are in 10⁻¹² cm; convert to fm.
274                for pt in &mut tab.points {
275                    pt.1 *= ENDF_RADIUS_TO_FM;
276                }
277                Some(tab)
278            } else {
279                None
280            };
281
282            // ENDF-6 Formats Manual: LRF values for resolved resonance region
283            // LRF=1: Single-Level Breit-Wigner (SLBW)
284            // LRF=2: Multi-Level Breit-Wigner (MLBW)
285            // LRF=3: Reich-Moore
286            // LRF=4: Adler-Adler (deprecated, not supported)
287            // LRF=7: R-Matrix Limited (general)
288            let formalism = match lrf {
289                1 => ResonanceFormalism::SLBW,
290                2 => ResonanceFormalism::MLBW,
291                3 => ResonanceFormalism::ReichMoore,
292                7 => ResonanceFormalism::RMatrixLimited,
293                _ => {
294                    return Err(EndfParseError::UnsupportedFormat(format!(
295                        "LRF={} not yet supported",
296                        lrf
297                    )));
298                }
299            };
300
301            let mut ctx = RangeParseContext {
302                lines: &lines,
303                pos: &mut pos,
304                energy_low,
305                energy_high,
306                naps,
307                ap_table,
308            };
309            let range = match formalism {
310                ResonanceFormalism::MLBW | ResonanceFormalism::SLBW => {
311                    parse_bw_range(&mut ctx, formalism)?
312                }
313                ResonanceFormalism::ReichMoore => parse_reich_moore_range(&mut ctx)?,
314                ResonanceFormalism::RMatrixLimited => skip_rmatrix_limited_range(&mut ctx)?,
315                ResonanceFormalism::Unresolved => {
316                    // Unreachable: Unresolved is only assigned in the LRU=2 branch above.
317                    unreachable!("Unresolved formalism should not appear in LRU=1 dispatch");
318                }
319                ResonanceFormalism::ScatteringRadiusOnly => {
320                    // Unreachable: ScatteringRadiusOnly is only assigned in the
321                    // LRU=0 branch above.
322                    unreachable!(
323                        "ScatteringRadiusOnly formalism should not appear in LRU=1 dispatch"
324                    );
325                }
326            };
327            all_ranges.push(range);
328        }
329    }
330
331    // Multi-MAT detection (#114, #123): since `lines` is pre-filtered to
332    // MF=2/MT=151, any unconsumed lines are definitively from another material.
333    // The previous character-based heuristic for distinguishing "real data" from
334    // SEND/FEND/MEND/TEND records was overly complex — those section-end records
335    // use different MF/MT codes and are already excluded by the filter above.
336    //
337    // Assumption: trailing whitespace-only lines that happen to pass the MF/MT
338    // filter (i.e. have " 2" at cols 70-72 and "151" at cols 72-75) would also
339    // trigger this check.  In practice, ENDF files do not contain such lines —
340    // trailing blanks either lack the MF/MT fields entirely or use MF=0/MT=0,
341    // both of which are excluded by the filter in `parse_endf_file2`.
342    if pos < lines.len() {
343        return Err(EndfParseError::UnsupportedFormat(
344            "Multiple materials detected in MF=2/MT=151: unconsumed data lines \
345             remain after parsing the first material. Multi-MAT files are not \
346             supported; split the file into single-material ENDF files."
347                .to_string(),
348        ));
349    }
350
351    let data = ResonanceData {
352        isotope,
353        za,
354        awr,
355        ranges: all_ranges,
356    };
357
358    // Fail loudly when the evaluation carries NO evaluable content. A file
359    // whose every range is a parse-and-skip placeholder (LRF=7, LRU=2, or an
360    // LRU=0 scattering-radius-only stanza) would otherwise "load" with zero
361    // resonances, return zero cross-sections everywhere, and produce
362    // transmission ≡ 1 with no signal to the caller. Files with at least one
363    // evaluable range still load: parse-and-skip exists for real mixed tapes
364    // (e.g. Ta-181, U-238, Pu-240) whose resolved ranges remain fully usable.
365    if !data.has_evaluable_range() {
366        let detail = if data.ranges.is_empty() {
367            "the file carries no resonance-parameter ranges at all".to_string()
368        } else {
369            let skipped: Vec<String> = data.ranges.iter().map(|r| r.skip_description()).collect();
370            format!(
371                "every range in this file is a parse-and-skip placeholder: {}",
372                skipped.join("; ")
373            )
374        };
375        return Err(EndfParseError::UnsupportedFormat(format!(
376            "No evaluable resonance ranges: NEREIDS evaluates resolved LRF=1/2/3 \
377             (SLBW/MLBW/Reich-Moore) only, and {detail}. Cross-sections would be \
378             identically zero (transmission = 1) over the full energy grid."
379        )));
380    }
381
382    Ok(data)
383}
384
385/// Shared context for ENDF range parsers.
386///
387/// Groups the file-position state (`lines`, `pos`) with the fields from the
388/// range CONT record that every range parser needs, eliminating long argument
389/// lists.
390struct RangeParseContext<'a> {
391    lines: &'a [&'a str],
392    pos: &'a mut usize,
393    energy_low: f64,
394    energy_high: f64,
395    naps: i32,
396    ap_table: Option<Tab1>,
397}
398
399/// Parse a Breit-Wigner (SLBW or MLBW) resolved resonance range.
400///
401/// ENDF-6 File 2, LRF=1 (SLBW) / LRF=2 (MLBW):
402/// - CONT: SPI, AP, 0, 0, NLS, 0
403/// - For each L-value:
404///   - CONT: AWRI, 0.0, L, 0, 6*NRS, NRS
405///   - LIST: NRS resonances, each 6 values: ER, AJ, GT, GN, GG, GF
406///
407/// Reference: ENDF-6 Formats Manual Section 2.2.1.1
408fn parse_bw_range(
409    ctx: &mut RangeParseContext<'_>,
410    formalism: ResonanceFormalism,
411) -> Result<ResonanceRange, EndfParseError> {
412    // CONT: SPI, AP, 0, 0, NLS, 0
413    // ENDF AP is in 10⁻¹² cm; convert to fm (×10).
414    let cont = parse_cont(ctx.lines, ctx.pos)?;
415    let target_spin = cont.c1;
416    let scattering_radius = cont.c2 * ENDF_RADIUS_TO_FM;
417    let nls = checked_count(cont.n1, "NLS")?; // number of L-values
418
419    // Zero NLS means a resolved range with no L-groups: out of spec
420    // (ENDF-6 §2.2.1.1 requires NLS >= 1; NLS=0 belongs to LRU=0
421    // scattering-radius-only stanzas) and silently inert if accepted —
422    // the range would count as evaluable yet contribute zero cross-section
423    // everywhere. Mirrors the NJS=0 guards in the URR skip paths.
424    if nls == 0 {
425        return Err(EndfParseError::UnsupportedFormat(format!(
426            "{formalism:?} range: NLS=0 (ENDF-6 §2.2.1.1 requires at least one \
427             L-group in a resolved range; NLS=0 is reserved for LRU=0)"
428        )));
429    }
430
431    let mut l_groups = Vec::with_capacity(nls);
432
433    for _ in 0..nls {
434        // CONT: AWRI, QX, L, LRX, 6*NRS, NRS
435        let l_cont = parse_cont(ctx.lines, ctx.pos)?;
436        let awr_l = l_cont.c1;
437        let qx = l_cont.c2; // Q-value for competitive width (eV)
438        // Validate L is non-negative (#123): negative L1 wraps to a huge u32.
439        if l_cont.l1 < 0 {
440            return Err(EndfParseError::UnsupportedFormat(format!(
441                "BW range: negative L={}",
442                l_cont.l1
443            )));
444        }
445        let l_val = l_cont.l1 as u32;
446        let lrx = l_cont.l2; // competitive width flag
447        let n1 = checked_count(l_cont.n1, "N1")?; // should be 6*NRS
448        let nrs = checked_count(l_cont.n2, "NRS")?; // number of resonances
449
450        // Validate N1 == 6*NRS (#123): a mismatch means the record is malformed
451        // and reading N1 values would over-/under-consume lines.
452        if n1 != 6 * nrs {
453            return Err(EndfParseError::UnsupportedFormat(format!(
454                "BW range L={l_val}: N1={n1} != 6*NRS={} (NRS={nrs})",
455                6 * nrs
456            )));
457        }
458
459        let mut resonances = Vec::with_capacity(nrs);
460
461        // Each resonance is 6 values on one line (or spanning lines).
462        // In ENDF format, LIST records pack 6 values per line.
463        let total_values = nrs * 6;
464        let values = parse_list_values(ctx.lines, ctx.pos, total_values)?;
465
466        for i in 0..nrs {
467            let base = i * 6;
468            resonances.push(Resonance {
469                energy: values[base],  // ER
470                j: values[base + 1],   // AJ
471                gn: values[base + 3],  // GN (neutron width)
472                gg: values[base + 4],  // GG (gamma width)
473                gfa: values[base + 5], // GF (fission width)
474                gfb: 0.0,              // Not used in BW
475                                       // Note: values[base+2] is GT (total width) — derived, not stored
476            });
477        }
478
479        l_groups.push(LGroup {
480            l: l_val,
481            awr: awr_l,
482            apl: 0.0, // Not in BW format
483            qx,
484            lrx,
485            resonances,
486        });
487    }
488
489    // A resolved range whose L-groups are all empty (every NRS=0) is accepted
490    // by reference readers (SAMMY, OpenScale), so it is NOT a hard error here.
491    // NEREIDS cannot evaluate it: its J-groups (and the hard-sphere
492    // potential-scattering term) derive from the resonance list, while SAMMY
493    // builds channels from quantum numbers and would retain hard-sphere
494    // scattering for the shape — a declared limitation. `is_evaluable()`
495    // therefore classifies it non-evaluable: a warn-and-skip placeholder like
496    // LRF=7/LRU=2/LRU=0, so a mixed file keeps its populated ranges, and a
497    // file where this is the only range fails the no-evaluable-content guard
498    // (fail-closed beats silently reporting zero as physics).
499
500    Ok(ResonanceRange {
501        energy_low: ctx.energy_low,
502        energy_high: ctx.energy_high,
503        resolved: true,
504        formalism,
505        target_spin,
506        scattering_radius,
507        naps: ctx.naps,
508        ap_table: ctx.ap_table.take(),
509        l_groups,
510        r_external: vec![],
511    })
512}
513
514/// Parse a Reich-Moore resolved resonance range.
515///
516/// ENDF-6 File 2, LRF=2:
517/// ENDF-6 File 2, LRF=3 (Reich-Moore):
518/// - CONT: SPI, AP, 0, 0, NLS, 0
519/// - For each L-value:
520///   - CONT: AWRI, APL, L, 0, 6*NRS, NRS
521///   - LIST: NRS resonances, each 6 values: ER, AJ, GN, GG, GFA, GFB
522///
523/// Reference: ENDF-6 Formats Manual Section 2.2.1.3
524/// Reference: SAMMY manual Section 2 (R-matrix theory)
525fn parse_reich_moore_range(
526    ctx: &mut RangeParseContext<'_>,
527) -> Result<ResonanceRange, EndfParseError> {
528    // CONT: SPI, AP, 0, 0, NLS, 0
529    // ENDF AP is in 10⁻¹² cm; convert to fm (×10).
530    let cont = parse_cont(ctx.lines, ctx.pos)?;
531    let target_spin = cont.c1;
532    let scattering_radius = cont.c2 * ENDF_RADIUS_TO_FM;
533    let nls = checked_count(cont.n1, "NLS")?; // number of L-values
534
535    // Zero NLS means a resolved range with no L-groups: out of spec
536    // (ENDF-6 §2.2.1.1 requires NLS >= 1; NLS=0 belongs to LRU=0
537    // scattering-radius-only stanzas) and silently inert if accepted —
538    // the range would count as evaluable yet contribute zero cross-section
539    // everywhere. Mirrors the NJS=0 guards in the URR skip paths.
540    if nls == 0 {
541        return Err(EndfParseError::UnsupportedFormat(
542            "Reich-Moore range: NLS=0 (ENDF-6 §2.2.1.1 requires at least one \
543             L-group in a resolved range; NLS=0 is reserved for LRU=0)"
544                .to_string(),
545        ));
546    }
547
548    let mut l_groups = Vec::with_capacity(nls);
549
550    for _ in 0..nls {
551        // CONT: AWRI, APL, L, 0, 6*NRS, NRS
552        let l_cont = parse_cont(ctx.lines, ctx.pos)?;
553        let awr_l = l_cont.c1;
554        let apl = l_cont.c2 * ENDF_RADIUS_TO_FM; // L-dependent scattering radius
555        // Validate L is non-negative (#123): negative L1 wraps to a huge u32.
556        if l_cont.l1 < 0 {
557            return Err(EndfParseError::UnsupportedFormat(format!(
558                "Reich-Moore range: negative L={}",
559                l_cont.l1
560            )));
561        }
562        let l_val = l_cont.l1 as u32;
563        let n1 = checked_count(l_cont.n1, "N1")?; // should be 6*NRS
564        let nrs = checked_count(l_cont.n2, "NRS")?; // number of resonances
565
566        // Validate N1 == 6*NRS (#123): a mismatch means the record is malformed
567        // and reading N1 values would over-/under-consume lines.
568        if n1 != 6 * nrs {
569            return Err(EndfParseError::UnsupportedFormat(format!(
570                "Reich-Moore range L={l_val}: N1={n1} != 6*NRS={} (NRS={nrs})",
571                6 * nrs
572            )));
573        }
574
575        let mut resonances = Vec::with_capacity(nrs);
576
577        // Each resonance is 6 values: ER, AJ, GN, GG, GFA, GFB
578        let total_values = nrs * 6;
579        let values = parse_list_values(ctx.lines, ctx.pos, total_values)?;
580
581        for i in 0..nrs {
582            let base = i * 6;
583            resonances.push(Resonance {
584                energy: values[base],  // ER (eV)
585                j: values[base + 1],   // AJ (total J)
586                gn: values[base + 2],  // GN (neutron width, eV)
587                gg: values[base + 3],  // GG (gamma width, eV)
588                gfa: values[base + 4], // GFA (fission width 1, eV)
589                gfb: values[base + 5], // GFB (fission width 2, eV)
590            });
591        }
592
593        l_groups.push(LGroup {
594            l: l_val,
595            awr: awr_l,
596            apl,
597            qx: 0.0, // Not used in Reich-Moore
598            lrx: 0,  // Not used in Reich-Moore
599            resonances,
600        });
601    }
602
603    // A resolved range whose L-groups are all empty (every NRS=0) is accepted
604    // by reference readers (SAMMY, OpenScale), so it is NOT a hard error here.
605    // NEREIDS cannot evaluate it: its J-groups (and the hard-sphere
606    // potential-scattering term) derive from the resonance list, while SAMMY
607    // builds channels from quantum numbers and would retain hard-sphere
608    // scattering for the shape — a declared limitation. `is_evaluable()`
609    // therefore classifies it non-evaluable: a warn-and-skip placeholder like
610    // LRF=7/LRU=2/LRU=0, so a mixed file keeps its populated ranges, and a
611    // file where this is the only range fails the no-evaluable-content guard
612    // (fail-closed beats silently reporting zero as physics).
613
614    Ok(ResonanceRange {
615        energy_low: ctx.energy_low,
616        energy_high: ctx.energy_high,
617        resolved: true,
618        formalism: ResonanceFormalism::ReichMoore,
619        target_spin,
620        scattering_radius,
621        naps: ctx.naps,
622        ap_table: ctx.ap_table.take(),
623        l_groups,
624        r_external: vec![],
625    })
626}
627
628/// Consume (skip) an R-Matrix Limited (LRF=7) resolved resonance range,
629/// advancing the line cursor past the particle-pair, channel, and resonance
630/// LIST records so any following range/material stays aligned.
631///
632/// NEREIDS does not evaluate LRF=7 cross sections — the RML physics was removed
633/// (its closed-channel treatment was incomplete: the Coulomb/SHF=1 closed-channel
634/// shift was unimplemented, and the evaluator was never validated against SAMMY)
635/// — so the parameters are discarded and the range is tagged
636/// `ResonanceFormalism::RMatrixLimited` (non-evaluable → Skip).
637///
638/// Record advancement and every guard of the removed evaluator are preserved
639/// verbatim, for two distinct reasons. Structural guards (KRM — it sets the
640/// NCH+1 vs NCH+2 row stride — and the NCH, IPP-range, NRS/NX/NPL checks)
641/// reject records that would otherwise misalign the stream. The quantum-flag
642/// guards (IFG/KRL, the PNT/mass particle-pair guards, KBK/KPS) do NOT
643/// affect the byte layout; they are retained as deliberate strictness parity
644/// with the removed evaluator — a file carrying flags the evaluator never
645/// supported is rejected rather than silently consumed.
646/// Only the RmlData/ParticlePair/RmlChannel/RmlResonance/SpinGroup construction
647/// is gone.
648///
649/// Reference: ENDF-6 Formats Manual §2.2.1.6; SAMMY rml/mrml01.f
650fn skip_rmatrix_limited_range(
651    ctx: &mut RangeParseContext<'_>,
652) -> Result<ResonanceRange, EndfParseError> {
653    // CONT: [SPI, AP, IFG, KRM, NJS, KRL]
654    // ENDF AP is in 10⁻¹² cm; convert to fm (×10).
655    let cont = parse_cont(ctx.lines, ctx.pos)?;
656    let target_spin = cont.c1;
657    let scattering_radius = cont.c2 * ENDF_RADIUS_TO_FM;
658    // IFG (L1): GAM representation flag (ENDF-6 §2.2.1.6). IFG=0 means channel
659    // widths GAM are given in eV; IFG=1 means reduced-width amplitudes (√eV).
660    // Only IFG=0 is supported; SAMMY's ENDF reader makes the same distinction.
661    let ifg = cont.l1;
662    if ifg != 0 {
663        return Err(EndfParseError::UnsupportedFormat(format!(
664            "LRF=7 IFG={ifg} (reduced-width amplitudes) is not supported (only \
665             IFG=0, channel widths in eV)"
666        )));
667    }
668    let krm = cont.l2 as u32; // R-matrix type: 2=standard, 3=Reich-Moore approx
669    // KRM=0/1/4 are defined in the ENDF spec but not supported here.
670    if krm != 2 && krm != 3 {
671        return Err(EndfParseError::UnsupportedFormat(format!(
672            "LRF=7 KRM={krm} is not supported (only KRM=2 and KRM=3)"
673        )));
674    }
675    let njs = checked_count(cont.n1, "NJS")?; // number of spin groups
676    // KRL (N2): kinematics flag. KRL=0 (non-relativistic) is universal;
677    // KRL=1 (relativistic) is not supported. SAMMY always writes 0.
678    let krl = cont.n2;
679    if krl != 0 {
680        return Err(EndfParseError::UnsupportedFormat(format!(
681            "LRF=7 KRL={krl} (relativistic kinematics) is not supported (only KRL=0)"
682        )));
683    }
684
685    // LIST: [0, 0, NPP, 0, 12*NPP, NPP]  — particle pair definitions.
686    // NPP is authoritative in L1. Reference: ENDF-6 §2.2.1.6 Table 2.1.
687    let pp_cont = parse_cont(ctx.lines, ctx.pos)?;
688    let npp = checked_count(pp_cont.l1, "NPP")?;
689    let pp_values = parse_list_values(ctx.lines, ctx.pos, npp * 12)?;
690
691    // Validate-and-narrow an ENDF integer-coded particle-pair flag (PNT/SHF).
692    // A fractional or non-finite f64 is a malformed record and must not be
693    // silently truncated/saturated (PNT=1.7→1, PNT=NaN→0 would bypass the {0,1}
694    // check below).
695    fn pp_int_flag(value: f64, field: &str, idx: usize) -> Result<i32, EndfParseError> {
696        if !value.is_finite() || value.fract() != 0.0 {
697            return Err(EndfParseError::UnsupportedFormat(format!(
698                "LRF=7 particle pair {idx}: {field}={value} is not a finite integer"
699            )));
700        }
701        Ok(value as i32)
702    }
703
704    // Particle-pair validation. Values are discarded (the pair defs are not
705    // stored), but every quantum-flag guard is applied so malformed records are
706    // rejected exactly as before.
707    for i in 0..npp {
708        let b = i * 12;
709        let ma = pp_values[b];
710        let mb = pp_values[b + 1];
711        let pnt = pp_int_flag(pp_values[b + 7], "PNT", i)?;
712        // SHF is validated-and-narrowed (must be 0 or 1) but otherwise unused
713        // in the skip path — see the Coulomb note below.
714        pp_int_flag(pp_values[b + 8], "SHF", i)?;
715        // PNT (Lpent) must be 0 or 1 (SAMMY rml/mrml03.f:22 Check_Quantum rejects
716        // Lpent ∉ {0,1}; PNT=2 "ASSIGN" is unimplemented).
717        if pnt != 0 && pnt != 1 {
718            return Err(EndfParseError::UnsupportedFormat(format!(
719                "LRF=7 particle pair {i}: PNT={pnt} is not supported (only PNT=0 \
720                 and PNT=1; SAMMY rejects Lpent outside {{0,1}})"
721            )));
722        }
723        // A massless pair (photon/eliminated channel, MA=0) must carry PNT=0.
724        if ma < 0.5 && pnt != 0 {
725            return Err(EndfParseError::UnsupportedFormat(format!(
726                "LRF=7 particle pair {i}: massless pair (MA={ma}) with PNT={pnt} is \
727                 invalid; a photon/eliminated channel must have PNT=0"
728            )));
729        }
730        // PNT=1 requires finite positive masses yielding a finite reduced mass
731        // μ = MA·MB/(MA+MB) (computed exactly as the physics did); catches
732        // overflow of MA·MB to ∞ and the MA+MB=0 / sign cases.
733        if pnt == 1 {
734            let reduced_mass = ma * mb / (ma + mb);
735            if !(ma.is_finite()
736                && mb.is_finite()
737                && ma > 0.0
738                && mb > 0.0
739                && reduced_mass.is_finite()
740                && reduced_mass > 0.0)
741            {
742                return Err(EndfParseError::UnsupportedFormat(format!(
743                    "LRF=7 particle pair {i}: PNT=1 requires finite positive masses \
744                     yielding a finite reduced mass (MA={ma}, MB={mb})"
745                )));
746            }
747        }
748        // Coulomb + SHF=1 is valid ENDF and SAMMY accepts it (mrml03.f rejects
749        // only SHF outside {0,1}; mrml07.f passes Ishift into the open-channel
750        // Coulomb calculation). The removed evaluator could not compute the
751        // closed-channel Coulomb shift and rejected the combination; with the
752        // parameters now discarded there is nothing to protect, and rejecting
753        // would fail a whole mixed file over a range that is skipped anyway.
754        // SHF is still validated-and-narrowed above (pp_int_flag), so a
755        // malformed flag remains a hard error.
756    }
757
758    for _ in 0..njs {
759        // LIST: [AJ, PJ, KBK, KPS, 6*(NCH+1), NCH+1]
760        let sg_cont = parse_cont(ctx.lines, ctx.pos)?;
761        let kbk = sg_cont.l1; // background R-matrix flag
762        let kps = sg_cont.l2; // phase shift flag
763
764        // KBK: background R-matrix correction (R-external function on a
765        // subset of channels). Per the printed ENDF-6 §2.2.1.6 Tables 2.4/2.5
766        // KBK is described as a nonzero flag with NCH background records,
767        // while the reference reader OpenScale
768        // (external/openScale/repo/packages/ScaleUtils/EndfLib/endf/File2.cpp:444-524)
769        // treats KBK as a sparse record count, with each subrecord's L1 holding
770        // the 1-based channel index and L2 holding the LBK formalism flag
771        // (LBK ∈ {0=no payload, 1=two TAB1, 2=LIST(5), 3=LIST(3)}). The two
772        // conventions disagree on (a) the loop bound, (b) the per-subrecord
773        // control-field positions, and (c) the payload shape per LBK value.
774        //
775        // No ENDF/B-VIII.0 evaluation in the local cache has nonzero KBK or
776        // KPS to disambiguate, and the only nonzero example located on disk
777        // is OpenScale's synthetic F-19 R-external test fixture
778        // (Ampx/TestRunner/test/data/polident/f19_rext.endf), which follows
779        // the OpenScale convention. NEREIDS's previous layout matched neither
780        // convention. Until a policy decision is made (strict-manual vs.
781        // OpenScale-compat) and a real ENDF/B-VIII.0 evaluation with R-external
782        // is available to validate against, reject nonzero KBK explicitly so
783        // the parser cannot silently misalign the stream past this spin group.
784        //
785        // The reject runs immediately after reading the spin-group CONT —
786        // before parsing the (potentially large) channel and resonance LISTs —
787        // so that unsupported files fail fast without wasting allocation and
788        // parsing work on records that will be discarded.
789        if kbk != 0 {
790            let nch_plus_one_raw = sg_cont.n2;
791            return Err(EndfParseError::UnsupportedFormat(format!(
792                "LRF=7 KBK={kbk} != 0 (R-external background) for spin group with \
793                 NCH+1={nch_plus_one_raw}: \
794                 the ENDF-6 manual vs. OpenScale layout dispute is unresolved and NEREIDS \
795                 does not yet parse nonzero KBK. Use the SAMMY .par/.inp converter \
796                 (sammy_to_resonance_data_multi) if R-external is required."
797            )));
798        }
799
800        // KPS: tabulated penetrability/phase-shift override per channel.
801        // Same documentation-vs-implementation dispute as KBK above
802        // (OpenScale File2.cpp:439-441 throws "kps > 0 for lrf=7 not yet
803        // supported" and never reads the subrecords). NEREIDS rejects nonzero
804        // KPS for the same reason: no validated reference layout, no real
805        // evaluation to test against.
806        if kps != 0 {
807            let nch_plus_one_raw = sg_cont.n2;
808            return Err(EndfParseError::UnsupportedFormat(format!(
809                "LRF=7 KPS={kps} != 0 (tabulated penetrability/phase-shift override) \
810                 for spin group with NCH+1={nch_plus_one_raw}: \
811                 NEREIDS does not yet parse nonzero KPS. \
812                 OpenScale itself rejects this case (\"kps > 0 for lrf=7 not yet supported\")."
813            )));
814        }
815
816        let npl = checked_count(sg_cont.n1, "NPL")?; // 6*(NCH+1)
817        let nch_plus_one = checked_count(sg_cont.n2, "NCH+1")?; // NCH+1
818
819        // NCH+1 <= 1 would imply zero physical channels (NCH = 0), which is
820        // meaningless for a resonance range — every spin group must have at
821        // least one channel. Reference: ENDF-6 §2.2.1.6; SAMMY rml/mrml01.f.
822        if nch_plus_one <= 1 {
823            return Err(EndfParseError::UnsupportedFormat(format!(
824                "RML spin-group LIST: NCH+1 must be >= 2, got NCH+1={nch_plus_one}"
825            )));
826        }
827        let nch = nch_plus_one - 1;
828
829        let sg_values = parse_list_values(ctx.lines, ctx.pos, npl)?;
830
831        // The LIST must carry at least 6*(NCH+1) values: the first row is the
832        // eliminated capture (gamma) channel — SAMMY reads all NCH+1 rows as
833        // channels, the first being the eliminated one — followed by the NCH
834        // particle channels.
835        // Reference: ENDF-6 §2.2.1.6 Table 2.3; SAMMY rml/mrml01.f lines
836        // 104-107, 381; ndf/WriteRrEndf.cpp:1033.
837        let expected_npl = 6 * (nch + 1);
838        if npl < expected_npl {
839            return Err(EndfParseError::UnsupportedFormat(format!(
840                "LRF=7 spin-group LIST: NPL={npl} < 6*(NCH+1)={expected_npl}"
841            )));
842        }
843
844        // Validate each particle channel's IPP (1-based particle-pair index)
845        // is in range; the channel definitions [IPP, L, SCH, BND, APE, APT]
846        // are otherwise discarded (LRF=7 is not evaluated). The first 6-value
847        // row is the eliminated capture channel, which carries no particle
848        // pair and is exempt from the IPP check.
849        // Reference: ENDF-6 §2.2.1.6; SAMMY rml/mrml01.f (Ippx test).
850        for c in 0..nch {
851            let b = 6 + c * 6; // skip the eliminated-capture-channel row
852            let ipp_raw = sg_values[b] as usize;
853            if ipp_raw == 0 || ipp_raw > npp {
854                return Err(EndfParseError::UnsupportedFormat(format!(
855                    "LRF=7 spin-group channel IPP={ipp_raw} is out of range 1..={npp}"
856                )));
857            }
858        }
859
860        // LIST: [0, 0, 0, NRS, 6*NX, NX]  — resonance parameters.
861        //
862        // ENDF-6 §2.2.1.6 fixes the control fields as [0, 0, 0, L2=NRS, N1=6*NX,
863        // N2=NX]: NRS (resonance count) is in L2; NX (packed 6-float row count =
864        // NRS·ceil(stride/6), stride NCH+1 for KRM=2 / NCH+2 for KRM=3) is in N2;
865        // N1 = 6*NX. For wide spin groups (e.g. F-19, NCH≥5) NX > NRS, so NRS
866        // MUST be read from L2 and NX from N2 — mixing them mis-sizes the block.
867        // SAMMY mrml01.f:413-415; OpenScale File2.cpp:415,686-697.
868        let res_cont = parse_cont(ctx.lines, ctx.pos)?;
869        let nrs = checked_count(res_cont.l2, "NRS")?;
870        let nx = checked_count(res_cont.n2, "NX")?;
871        let res_npl = checked_count(res_cont.n1, "NPL")?;
872        if res_npl != 6 * nx {
873            return Err(EndfParseError::UnsupportedFormat(format!(
874                "LRF=7 resonance LIST: N1 ({res_npl}) != 6 * N2 ({}); ENDF-6 §2.2.1.6 \
875                 requires NPL = 6*NX for the packed-row layout",
876                6 * nx
877            )));
878        }
879        // The per-resonance row count is constant within a spin group, so NX is
880        // always an integer multiple of NRS; a non-multiple would yield a
881        // fractional stride and mis-align resonance reads.
882        if nrs > 0 && nx % nrs != 0 {
883            return Err(EndfParseError::UnsupportedFormat(format!(
884                "LRF=7 resonance LIST: N2/NX ({nx}) is not a multiple of L2/NRS ({nrs}); \
885                 ENDF-6 §2.2.1.6 requires NX = NRS * ceil(stride/6) where stride is \
886                 NCH+1 for KRM=2 and NCH+2 for KRM=3"
887            )));
888        }
889        // Canonical empty spin group (ENDF-6 §2.2.1.6 + OpenScale File2.cpp:
890        // 683-697): NRS=0 carries a single zero-filler row, so NX must be 1.
891        if nrs == 0 && nx != 1 {
892            return Err(EndfParseError::UnsupportedFormat(format!(
893                "LRF=7 resonance LIST: NRS=0 requires NX=1 (single zero-filler row \
894                 per ENDF-6 §2.2.1.6 + OpenScale File2.cpp:683-697); got NX={nx}"
895            )));
896        }
897        // Consume the resonance block, then validate the KRM-dependent stride so
898        // a malformed row layout is rejected rather than silently accepted.
899        // KRM=2 per-resonance row = [ER, γ_1..γ_NCH, pad] → stride ≥ NCH+1;
900        // KRM=3 = [ER, Γγ, Γ_1..Γ_NCH, pad] → stride ≥ NCH+2.
901        // Reference: ENDF-6 §2.2.1.6; SAMMY rml/mrml01.f ENDF123.
902        parse_list_values(ctx.lines, ctx.pos, res_npl)?;
903        if nrs > 0 {
904            let min_stride = if krm == 3 { nch + 2 } else { nch + 1 };
905            if res_npl % nrs != 0 {
906                return Err(EndfParseError::UnsupportedFormat(format!(
907                    "LRF=7 resonance block NPL={res_npl} is not divisible by NRS={nrs}"
908                )));
909            }
910            let s = res_npl / nrs;
911            if s < min_stride {
912                return Err(EndfParseError::UnsupportedFormat(format!(
913                    "LRF=7 resonance stride={s} < {}={min_stride} \
914                     (KRM={krm}, NPL={res_npl}, NRS={nrs})",
915                    if krm == 3 { "NCH+2" } else { "NCH+1" }
916                )));
917            }
918        }
919    }
920
921    Ok(ResonanceRange {
922        energy_low: ctx.energy_low,
923        energy_high: ctx.energy_high,
924        resolved: true,
925        formalism: ResonanceFormalism::RMatrixLimited,
926        target_spin,
927        scattering_radius,
928        naps: ctx.naps,
929        ap_table: ctx.ap_table.take(),
930        l_groups: Vec::new(),
931        r_external: vec![],
932    })
933}
934
935/// Maximum sane ENDF count value.
936///
937/// ENDF files in practice never contain more than ~100k resonances per section.
938/// Accepting `i32::MAX` would cause enormous allocations (gigabytes) on
939/// malformed files.  This cap is generous enough for any real evaluation while
940/// protecting against allocation bombs.
941const MAX_ENDF_COUNT: i32 = 1_000_000;
942
943/// Validate that an ENDF integer count is non-negative and return as `usize`.
944///
945/// Malformed records can contain negative counts which, if cast directly to
946/// `usize`, wrap to huge values and cause OOM panics in `Vec::with_capacity`
947/// or `parse_list_values`.
948fn checked_count(value: i32, label: &str) -> Result<usize, EndfParseError> {
949    if value < 0 {
950        return Err(EndfParseError::UnsupportedFormat(format!(
951            "Negative ENDF count: {label}={value}"
952        )));
953    }
954    if value > MAX_ENDF_COUNT {
955        return Err(EndfParseError::UnsupportedFormat(format!(
956            "ENDF count too large: {label}={value} (maximum {MAX_ENDF_COUNT})"
957        )));
958    }
959    Ok(value as usize)
960}
961
962// ---------------------------------------------------------------------------
963// Low-level ENDF line parsing helpers
964// ---------------------------------------------------------------------------
965
966/// Parsed CONT (control) record with 2 floats, 4 integers.
967struct ContRecord {
968    c1: f64,
969    c2: f64,
970    l1: i32,
971    l2: i32,
972    n1: i32,
973    n2: i32,
974}
975
976/// Parse a CONT record from the current line.
977fn parse_cont(lines: &[&str], pos: &mut usize) -> Result<ContRecord, EndfParseError> {
978    if *pos >= lines.len() {
979        return Err(EndfParseError::UnexpectedEof(
980            "Expected CONT record but reached end of data".to_string(),
981        ));
982    }
983    let line = lines[*pos];
984    *pos += 1;
985
986    Ok(ContRecord {
987        c1: parse_endf_float(line, 0)?,
988        c2: parse_endf_float(line, 1)?,
989        l1: parse_endf_int(line, 2)?,
990        l2: parse_endf_int(line, 3)?,
991        n1: parse_endf_int(line, 4)?,
992        n2: parse_endf_int(line, 5)?,
993    })
994}
995
996/// Parse a LIST of floating-point values spanning multiple lines.
997///
998/// ENDF packs 6 values per line. We read ceil(n/6) lines.
999fn parse_list_values(
1000    lines: &[&str],
1001    pos: &mut usize,
1002    n_values: usize,
1003) -> Result<Vec<f64>, EndfParseError> {
1004    let mut values = Vec::with_capacity(n_values);
1005    let n_lines = n_values.div_ceil(6);
1006
1007    for _ in 0..n_lines {
1008        if *pos >= lines.len() {
1009            return Err(EndfParseError::UnexpectedEof(
1010                "Expected LIST data but reached end".to_string(),
1011            ));
1012        }
1013        let line = lines[*pos];
1014        *pos += 1;
1015
1016        let remaining = n_values - values.len();
1017        let fields_on_line = remaining.min(6);
1018
1019        for field in 0..fields_on_line {
1020            values.push(parse_endf_float(line, field)?);
1021        }
1022    }
1023
1024    Ok(values)
1025}
1026
1027/// Parse a floating-point value from an 11-character ENDF field.
1028///
1029/// ENDF uses Fortran-style floats that may omit 'E', e.g.:
1030/// - " 1.234567+2" means 1.234567e+2
1031/// - "-3.456789-1" means -3.456789e-1
1032/// - " 0.000000+0" means 0.0
1033fn parse_endf_float(line: &str, field_index: usize) -> Result<f64, EndfParseError> {
1034    let start = field_index * 11;
1035    let end = start + 11;
1036
1037    if line.len() < end {
1038        // Short line — treat as zero.
1039        return Ok(0.0);
1040    }
1041
1042    let field = &line[start..end];
1043    let trimmed = field.trim();
1044
1045    if trimmed.is_empty() {
1046        return Ok(0.0);
1047    }
1048
1049    // Try standard Rust float parsing first.
1050    if let Ok(v) = trimmed.parse::<f64>() {
1051        return Ok(v);
1052    }
1053
1054    // Handle Fortran-style: "1.234567+2" or "-3.456789-1"
1055    // Look for +/- that is NOT the first character and NOT preceded by 'e'/'E'/'d'/'D'.
1056    let bytes = trimmed.as_bytes();
1057    for i in 1..bytes.len() {
1058        if (bytes[i] == b'+' || bytes[i] == b'-')
1059            && bytes[i - 1] != b'e'
1060            && bytes[i - 1] != b'E'
1061            && bytes[i - 1] != b'd'
1062            && bytes[i - 1] != b'D'
1063            && bytes[i - 1] != b'+'
1064            && bytes[i - 1] != b'-'
1065        {
1066            let mantissa = &trimmed[..i];
1067            let exp_slice = &trimmed[i..];
1068            // Strip spaces from the exponent only when present (some ENDF files
1069            // write "+ 4" not "+4").  Avoid allocation on the common path.
1070            let with_e = if exp_slice.contains(' ') {
1071                let exponent: String = exp_slice.chars().filter(|c| !c.is_whitespace()).collect();
1072                format!("{}E{}", mantissa, exponent)
1073            } else {
1074                format!("{}E{}", mantissa, exp_slice)
1075            };
1076            if let Ok(v) = with_e.parse::<f64>() {
1077                return Ok(v);
1078            }
1079        }
1080    }
1081
1082    Err(EndfParseError::InvalidNumber(format!(
1083        "Cannot parse ENDF float: '{}'",
1084        field
1085    )))
1086}
1087
1088/// Parse an integer from an 11-character ENDF field.
1089fn parse_endf_int(line: &str, field_index: usize) -> Result<i32, EndfParseError> {
1090    let start = field_index * 11;
1091    let end = start + 11;
1092
1093    if line.len() < end {
1094        return Ok(0);
1095    }
1096
1097    let field = &line[start..end];
1098    let trimmed = field.trim();
1099
1100    if trimmed.is_empty() {
1101        return Ok(0);
1102    }
1103
1104    // ENDF integers may have a decimal point (e.g., "1.000000+0" for 1).
1105    // Try integer parse first, then float-with-integral-value.
1106    if let Ok(v) = trimmed.parse::<i32>() {
1107        return Ok(v);
1108    }
1109
1110    // Try parsing as float, but reject non-integral values (e.g., "1.9e+0")
1111    // rather than silently truncating.  Use the same ε=1e-6 tolerance as
1112    // `parse_tab1`'s NBT/INT validation so that integer fields stored as
1113    // ENDF floats ("1.000000+0") still round-trip, but a malformed
1114    // "1.900000+0" is surfaced as an InvalidNumber rather than parsed as 1.
1115    if let Ok(v) = parse_endf_float(line, field_index) {
1116        if (v - v.round()).abs() > 1e-6 {
1117            return Err(EndfParseError::InvalidNumber(format!(
1118                "Non-integral value in ENDF int field: '{}' (={})",
1119                field, v
1120            )));
1121        }
1122        return Ok(v.round() as i32);
1123    }
1124
1125    Err(EndfParseError::InvalidNumber(format!(
1126        "Cannot parse ENDF int: '{}'",
1127        field
1128    )))
1129}
1130
1131/// Consume (skip) a URR LFW=1/LRF=1 "Case B" range body (ENDF-6 §2.2.2.1),
1132/// advancing the line cursor past the shared fission-width energy LIST and each
1133/// per-(L,J) LIST record. NEREIDS does not evaluate URR cross sections, so the
1134/// parameter values are discarded; only the record structure is read, which is
1135/// what keeps the line stream aligned for any following range or SEND record.
1136/// Returns the header's `(SPI, AP)` — AP converted to fm — so the caller can
1137/// preserve them on the placeholder range.
1138///
1139/// The per-J control line (carrying `N1 = NE+6`) MUST be consumed before its
1140/// body — omitting it misaligns the stream by one record per J-group. The
1141/// `N1 = NE+6` guard is preserved to catch malformed records.
1142///
1143/// Reference: ENDF-6 §2.2.2.1 Case B; OpenScale `File2.cpp`
1144/// (`lfw==1 && lrf==1` branch) and AMPX `File2Unres.f90` — both OpenScale/AMPX
1145/// readers vendored under SAMMY's external tree, not SAMMY proper.
1146fn skip_urr_lfw1_lrf1(lines: &[&str], pos: &mut usize) -> Result<(f64, f64), EndfParseError> {
1147    // CONT: SPI, AP, LSSF, 0, NE, NLS
1148    let header = parse_cont(lines, pos)?;
1149    let spi = header.c1;
1150    let ap_fm = header.c2 * ENDF_RADIUS_TO_FM;
1151    let ne = checked_count(header.n1, "NE")?;
1152    let nls = checked_count(header.n2, "NLS")?;
1153
1154    // LIST: NE shared fission-width-grid energies.
1155    parse_list_values(lines, pos, ne)?;
1156
1157    for _ in 0..nls {
1158        // CONT: AWRI, 0, L, 0, NJS, 0
1159        let l_cont = parse_cont(lines, pos)?;
1160        let njs = checked_count(l_cont.n1, "NJS")?;
1161
1162        // Zero NJS means no J-groups for this L-value, which is malformed
1163        // (ENDF §2.2.2.1 requires at least one J-group per L-group) and would
1164        // under-consume the body, surfacing as a confusing downstream error.
1165        // Mirrors the NJS=0 guards in `skip_urr_range`.
1166        if njs == 0 {
1167            return Err(EndfParseError::UnsupportedFormat(format!(
1168                "URR LFW=1/LRF=1 L={}: NJS=0 (at least one J-group required)",
1169                l_cont.l1
1170            )));
1171        }
1172
1173        for _ in 0..njs {
1174            // Per-J LIST control: 0.0, 0.0, L, MUF, NE+6, 0.  Consuming this
1175            // control record keeps the line stream aligned — the body follows.
1176            let j_cont = parse_cont(lines, pos)?;
1177            let n1 = checked_count(j_cont.n1, "N1")?;
1178            // SCALE validates this exact relation (File2.cpp: N1-6 == NE).
1179            if n1 != ne + 6 {
1180                return Err(EndfParseError::UnsupportedFormat(format!(
1181                    "URR LFW=1/LRF=1: per-J N1={n1} ≠ NE+6={} (NE={ne})",
1182                    ne + 6
1183                )));
1184            }
1185            // LIST body: [D, AJ, AMUN, GNO, GG, 0] + NE fission widths (discarded).
1186            parse_list_values(lines, pos, n1)?;
1187        }
1188    }
1189
1190    Ok((spi, ap_fm))
1191}
1192
1193/// Consume (skip) a URR (LRU=2) range body of the given LRF, advancing the line
1194/// cursor past every L- and (L,J)-record. NEREIDS does not evaluate URR cross
1195/// sections, so the parameter values are discarded; only the record structure
1196/// is read, which keeps the line stream aligned for any following range or SEND
1197/// record. Returns the header's `(SPI, AP)` — AP converted to fm — so the
1198/// caller can preserve them on the placeholder range.
1199///
1200/// Handles LFW=0/LRF=1 (energy-independent widths) and LRF=2 (tabulated
1201/// widths); LFW=1/LRF=2 is byte-identical to LFW=0/LRF=2 and routes here too.
1202/// LFW=1/LRF=1 (shared-grid "Case B") is handled by `skip_urr_lfw1_lrf1`.
1203///
1204/// The structural guards (N1=6*NJS for LRF=1; INT∈1..=5 and N1=6*(NE+1) for
1205/// LRF=2; NJS>0) are preserved: they catch malformed records that would
1206/// otherwise over-/under-consume lines and misalign the cursor.
1207///
1208/// Reference: ENDF-6 Formats Manual §2.2.2
1209fn skip_urr_range(lines: &[&str], pos: &mut usize, lrf: i32) -> Result<(f64, f64), EndfParseError> {
1210    // CONT: SPI, AP, 0, 0, NLS, 0
1211    let spi_cont = parse_cont(lines, pos)?;
1212    let spi = spi_cont.c1;
1213    let ap_fm = spi_cont.c2 * ENDF_RADIUS_TO_FM;
1214    let nls = checked_count(spi_cont.n1, "NLS")?;
1215
1216    if lrf == 1 {
1217        // LRF=1: energy-independent widths, one LIST block per L covering all J.
1218        for _ in 0..nls {
1219            // CONT: AWRI, 0, L, 0, N1=6*NJS, N2=NJS
1220            let l_cont = parse_cont(lines, pos)?;
1221            if l_cont.l1 < 0 {
1222                return Err(EndfParseError::UnsupportedFormat(format!(
1223                    "URR LRF=1: negative L={}",
1224                    l_cont.l1
1225                )));
1226            }
1227            let l = l_cont.l1 as u32;
1228            let n1 = checked_count(l_cont.n1, "N1")?; // 6*NJS
1229            let njs = checked_count(l_cont.n2, "NJS")?;
1230            if njs == 0 || n1 != 6 * njs {
1231                return Err(EndfParseError::UnsupportedFormat(format!(
1232                    "URR LRF=1 L={l}: N1={n1} ≠ 6×NJS={} (NJS={njs})",
1233                    6 * njs
1234                )));
1235            }
1236            // LIST body: [D, AJ, AMUN, GNO, GG, GF] × NJS (discarded).
1237            parse_list_values(lines, pos, n1)?;
1238        }
1239    } else {
1240        // LRF=2: energy-dependent width tables, one LIST per (L, J).
1241        for _ in 0..nls {
1242            // CONT: AWRI, 0, L, 0, NJS, 0
1243            let l_cont = parse_cont(lines, pos)?;
1244            if l_cont.l1 < 0 {
1245                return Err(EndfParseError::UnsupportedFormat(format!(
1246                    "URR LRF=2: negative L={}",
1247                    l_cont.l1
1248                )));
1249            }
1250            let l = l_cont.l1 as u32;
1251            let njs = checked_count(l_cont.n1, "NJS")?; // N1 = NJS for LRF=2
1252
1253            // Zero NJS means no J-groups for this L-value, which is malformed
1254            // (ENDF §2.2.2.2 requires at least one J-group per L-group).
1255            if njs == 0 {
1256                return Err(EndfParseError::UnsupportedFormat(format!(
1257                    "URR LRF=2 L={l}: NJS=0 (at least one J-group required)"
1258                )));
1259            }
1260
1261            for _ in 0..njs {
1262                // CONT: AJ, 0, INT, 0, N1=6*(NE+1), N2=NE
1263                let j_cont = parse_cont(lines, pos)?;
1264                let int_code = j_cont.l1; // interpolation law (L1 field)
1265                // ENDF-6 §0.5 defines INT codes 1..=5; anything outside that
1266                // range — including negative values and INT=0 or INT≥6 — is a
1267                // malformed record.
1268                if !(1..=5).contains(&int_code) {
1269                    return Err(EndfParseError::UnsupportedFormat(format!(
1270                        "URR LRF=2: INT={int_code} out of spec (expected 1..=5)"
1271                    )));
1272                }
1273                let n1 = checked_count(j_cont.n1, "N1")?; // 6*(NE+1)
1274                let ne = checked_count(j_cont.n2, "NE")?; // NE (number of energy points)
1275
1276                // Validate N1 = 6*(NE+1) before consuming the LIST body so a
1277                // malformed record cannot over-/under-consume lines.
1278                let expected_n1 = 6 * (ne + 1);
1279                if n1 != expected_n1 {
1280                    return Err(EndfParseError::UnsupportedFormat(format!(
1281                        "URR LRF=2: N1={n1} ≠ 6*(NE+1)={expected_n1} (NE={ne})"
1282                    )));
1283                }
1284                // LIST body: DOF row + NE width rows (discarded).
1285                parse_list_values(lines, pos, n1)?;
1286            }
1287        }
1288    }
1289
1290    Ok((spi, ap_fm))
1291}
1292
1293/// Parse a TAB1 record into a `Tab1` interpolation table.
1294///
1295/// ENDF TAB1 layout (Reference: ENDF-6 Formats Manual §0.5):
1296/// ```text
1297/// CONT: [C1, C2, L1, L2, NR, NP]
1298/// NR×2 integer values: (NBT_i, INT_i) pairs  — 6 per line
1299/// NP×2 float values:   (x_i,   y_i)   pairs  — 6 per line
1300/// ```
1301///
1302/// INT codes: 1=histogram, 2=lin-lin, 3=log-x/lin-y, 4=lin-x/log-y, 5=log-log.
1303fn parse_tab1(lines: &[&str], pos: &mut usize) -> Result<Tab1, EndfParseError> {
1304    let cont = parse_cont(lines, pos)?;
1305    let nr = checked_count(cont.n1, "NR")?; // number of interpolation regions
1306    let np = checked_count(cont.n2, "NP")?; // number of data points
1307
1308    // NR=0 is valid ENDF: it means a single implicit interpolation region
1309    // covering all NP points with no explicit boundary record.  The
1310    // evaluate() call will fall through to the `unwrap_or(2)` default in
1311    // interp_code_for_interval(), which correctly returns INT=2 (lin-lin).
1312    // When NR=0, the loop below is a no-op and the interp_raw vec stays empty.
1313
1314    // Read NR×2 integers: (NBT, INT) pairs packed as ENDF floats.
1315    // Validate that values are integers, INT codes are in 1..=5, boundaries
1316    // are strictly increasing, and the last boundary equals NP.
1317    let interp_raw = parse_list_values(lines, pos, nr * 2)?;
1318    let mut boundaries = Vec::with_capacity(nr);
1319    let mut interp_codes = Vec::with_capacity(nr);
1320    for i in 0..nr {
1321        let nbt_raw = interp_raw[i * 2];
1322        let int_raw = interp_raw[i * 2 + 1];
1323
1324        // ENDF stores integers as floats (e.g. "2.000000+0").  They must be
1325        // exact whole numbers.  Use a small epsilon (1e-6) rather than the
1326        // half-unit tolerance 0.5, which would silently accept 1.4 or 2.49.
1327        // NBT is a 1-based index (ENDF §0.5), so 0 is invalid.
1328        if (nbt_raw - nbt_raw.round()).abs() > 1e-6 || nbt_raw < 1.0 {
1329            return Err(EndfParseError::UnsupportedFormat(format!(
1330                "TAB1 NBT[{}] is not a positive integer: {}",
1331                i, nbt_raw
1332            )));
1333        }
1334        if (int_raw - int_raw.round()).abs() > 1e-6 {
1335            return Err(EndfParseError::UnsupportedFormat(format!(
1336                "TAB1 INT[{}] is not an integer: {}",
1337                i, int_raw
1338            )));
1339        }
1340        let int_code = int_raw.round() as u32;
1341        if !(1..=5).contains(&int_code) {
1342            return Err(EndfParseError::UnsupportedFormat(format!(
1343                "TAB1 INT[{}]={} is out of range 1..=5",
1344                i, int_code
1345            )));
1346        }
1347        let nbt = nbt_raw.round() as usize;
1348
1349        // Boundaries must be strictly increasing (ENDF §0.5).
1350        if let Some(&prev) = boundaries.last()
1351            && nbt <= prev
1352        {
1353            return Err(EndfParseError::UnsupportedFormat(format!(
1354                "TAB1 NBT[{}]={} is not greater than NBT[{}]={}",
1355                i,
1356                nbt,
1357                i - 1,
1358                prev
1359            )));
1360        }
1361        boundaries.push(nbt);
1362        interp_codes.push(int_code);
1363    }
1364
1365    // The final boundary must equal NP (ENDF §0.5: last NBT is 1-based index of last point).
1366    if nr > 0 {
1367        let last_nbt = *boundaries.last().unwrap();
1368        if last_nbt != np {
1369            return Err(EndfParseError::UnsupportedFormat(format!(
1370                "TAB1 last NBT={} does not equal NP={}",
1371                last_nbt, np
1372            )));
1373        }
1374    }
1375
1376    if np == 0 {
1377        return Err(EndfParseError::UnsupportedFormat(
1378            "TAB1 NP=0: table must have at least one point".to_string(),
1379        ));
1380    }
1381
1382    // Read NP×2 floats: (E, AP) pairs.
1383    let data_raw = parse_list_values(lines, pos, np * 2)?;
1384    let mut points = Vec::with_capacity(np);
1385    for i in 0..np {
1386        let x = data_raw[i * 2];
1387        let y = data_raw[i * 2 + 1];
1388        // x-values must be strictly increasing; Tab1::evaluate() relies on this.
1389        if let Some(&(x_prev, _)) = points.last()
1390            && x <= x_prev
1391        {
1392            return Err(EndfParseError::UnsupportedFormat(format!(
1393                "TAB1 x[{}]={} is not greater than x[{}]={} (x must be strictly increasing)",
1394                i,
1395                x,
1396                i - 1,
1397                x_prev
1398            )));
1399        }
1400        points.push((x, y));
1401    }
1402
1403    Ok(Tab1 {
1404        boundaries,
1405        interp_codes,
1406        points,
1407    })
1408}
1409
1410/// Errors from ENDF parsing.
1411#[derive(Debug, thiserror::Error)]
1412pub enum EndfParseError {
1413    #[error("Missing section: {0}")]
1414    MissingSection(String),
1415
1416    #[error("Unsupported format: {0}")]
1417    UnsupportedFormat(String),
1418
1419    #[error("Invalid number: {0}")]
1420    InvalidNumber(String),
1421
1422    #[error("Unexpected end of file: {0}")]
1423    UnexpectedEof(String),
1424
1425    #[error("Invalid isotope: {0}")]
1426    InvalidIsotope(#[from] nereids_core::error::NereidsError),
1427}
1428
1429#[cfg(test)]
1430mod tests {
1431    use super::*;
1432
1433    // NOTE: Every ENDF test fixture line must be at least 75 characters long.
1434    // The MF/MT filter in `parse_endf_file2` checks `line.len() < 75` and
1435    // discards shorter lines.  ENDF lines are exactly 80 characters in the
1436    // real format.  If a test fixture line is truncated below 75 chars, it
1437    // will be silently dropped and the test will fail with "No MF=2, MT=151
1438    // data found" rather than a useful error.
1439
1440    #[test]
1441    fn test_parse_endf_float_standard() {
1442        // ENDF fields are exactly 11 chars wide, no separators.
1443        // " 1.23456+2" in 11 chars = " 1.23456+02" (Fortran E11.4 style)
1444        //  01234567890  (field 0: cols 0-10, field 1: cols 11-21, etc.)
1445        let line = " 1.23456+02 2.34567-01 0.00000+00                                            ";
1446        assert!((parse_endf_float(line, 0).unwrap() - 123.456).abs() < 0.01);
1447        assert!((parse_endf_float(line, 1).unwrap() - 0.234567).abs() < 1e-6);
1448        assert!((parse_endf_float(line, 2).unwrap() - 0.0).abs() < 1e-10);
1449    }
1450
1451    #[test]
1452    fn test_parse_endf_float_with_e() {
1453        // 11-char fields: "1.23456E+02" "2.34567E-01"
1454        let line = "1.23456E+022.34567E-01                                                       ";
1455        assert!((parse_endf_float(line, 0).unwrap() - 123.456).abs() < 0.01);
1456        assert!((parse_endf_float(line, 1).unwrap() - 0.234567).abs() < 1e-6);
1457    }
1458
1459    #[test]
1460    fn test_parse_endf_float_negative() {
1461        let line = "-1.23456+02-2.34567-01                                                       ";
1462        assert!((parse_endf_float(line, 0).unwrap() - (-123.456)).abs() < 0.01);
1463        assert!((parse_endf_float(line, 1).unwrap() - (-0.234567)).abs() < 1e-6);
1464    }
1465
1466    /// Fortran exponents with a space between the sign and digit — e.g. "9.22330+ 4"
1467    /// — appear in some older ENDF evaluations (observed in SAMMY tr149/t149a.endf
1468    /// for U-233).  The parser strips the space before parsing the exponent.
1469    #[test]
1470    fn test_parse_endf_float_spaced_exponent() {
1471        // " 9.22330+ 4" occupies 11 chars: space before mantissa, space before digit
1472        let line =
1473            " 9.22330+ 4 1.23400- 2                                                         ";
1474        assert!((parse_endf_float(line, 0).unwrap() - 92_233.0).abs() < 1.0);
1475        assert!((parse_endf_float(line, 1).unwrap() - 0.01234).abs() < 1e-6);
1476    }
1477
1478    #[test]
1479    fn test_parse_endf_int() {
1480        let line = "          0          1          2          3          4          5            ";
1481        assert_eq!(parse_endf_int(line, 0).unwrap(), 0);
1482        assert_eq!(parse_endf_int(line, 1).unwrap(), 1);
1483        assert_eq!(parse_endf_int(line, 2).unwrap(), 2);
1484    }
1485
1486    /// Parse the vendored U-238 ENDF file (Reich-Moore, LRF=3).
1487    ///
1488    /// This test validates against the public-domain U-238 ENDF/B-VIII.0
1489    /// evaluation shipped at `examples/data/u238_ex027.endf` (the same
1490    /// file SAMMY distributes as `samexm_new/ex027_new/ex027.endf`). The
1491    /// first positive-energy resonance of U-238 is at 6.674 eV.
1492    ///
1493    /// Vendored under public-domain ENDF/B redistribution, so this gate
1494    /// runs unconditionally on CI — no `Skipping…` fall-through.
1495    #[test]
1496    fn test_parse_u238_sammy_endf() {
1497        // Crate-local copy so the test works when nereids-endf is built
1498        // standalone (outside the workspace, where `examples/data/` is
1499        // not packaged).  The original `examples/data/u238_ex027.endf`
1500        // is kept for end-user example code.
1501        let endf_path =
1502            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/data/u238_ex027.endf");
1503
1504        let endf_text = std::fs::read_to_string(&endf_path)
1505            .unwrap_or_else(|e| panic!("vendored U-238 fixture missing at {endf_path:?}: {e}"));
1506        let data = parse_endf_file2(&endf_text).unwrap();
1507
1508        // Basic structure checks.
1509        assert_eq!(data.za, 92238, "Should be U-238");
1510        assert!((data.awr - 236.006).abs() < 0.01, "AWR should be ~236");
1511        assert!(!data.ranges.is_empty(), "Should have at least one range");
1512
1513        let range = &data.ranges[0];
1514        assert!(range.resolved, "First range should be resolved");
1515        assert_eq!(
1516            range.formalism,
1517            ResonanceFormalism::ReichMoore,
1518            "U-238 ENDF uses Reich-Moore (LRF=3)"
1519        );
1520        assert!(
1521            (range.target_spin - 0.0).abs() < 1e-10,
1522            "U-238 target spin I=0"
1523        );
1524        assert!(
1525            (range.scattering_radius - 9.4285).abs() < 0.01,
1526            "Scattering radius ~9.4285 fm (ENDF 0.94285 × 10)"
1527        );
1528        assert_eq!(range.l_groups.len(), 2, "Should have L=0 and L=1 groups");
1529
1530        // Check first L-group (L=0).
1531        let l0 = &range.l_groups[0];
1532        assert_eq!(l0.l, 0, "First group should be L=0");
1533        assert!(
1534            l0.resonances.len() > 500,
1535            "L=0 should have hundreds of resonances"
1536        );
1537
1538        // Find the famous 6.674 eV resonance of U-238.
1539        let first_positive = l0
1540            .resonances
1541            .iter()
1542            .find(|r| r.energy > 0.0)
1543            .expect("Should have positive-energy resonances");
1544        assert!(
1545            (first_positive.energy - 6.674).abs() < 0.01,
1546            "First positive resonance should be at 6.674 eV, got {}",
1547            first_positive.energy
1548        );
1549        assert!(
1550            (first_positive.j - 0.5).abs() < 1e-10,
1551            "6.674 eV resonance has J=0.5"
1552        );
1553
1554        // The 6.674 eV resonance neutron width: ~1.493e-3 eV
1555        assert!(
1556            (first_positive.gn - 1.493e-3).abs() < 1e-5,
1557            "Neutron width should be ~1.493e-3 eV, got {}",
1558            first_positive.gn
1559        );
1560        // Gamma width: ~2.3e-2 eV
1561        assert!(
1562            (first_positive.gg - 2.3e-2).abs() < 1e-3,
1563            "Gamma width should be ~2.3e-2 eV, got {}",
1564            first_positive.gg
1565        );
1566
1567        // The aggregate accessor must cover at least the resolved range's
1568        // resonances (it sums over every range, including any unresolved
1569        // range the file carries).
1570        let total = data.total_resonance_count();
1571        let resolved_sum: usize = range.l_groups.iter().map(|g| g.resonances.len()).sum();
1572        assert!(
1573            total >= resolved_sum && resolved_sum > 500,
1574            "total_resonance_count ({total}) must cover the resolved range's \
1575             {resolved_sum} resonances"
1576        );
1577    }
1578
1579    /// Pin the Ta-181 ENDF/B-VIII.0 resonance count at 76 (genuinely-sparse RRR).
1580    ///
1581    /// Ta-181 (MAT 7328) in ENDF/B-VIII.0 has NER=2 ranges:
1582    ///   range 0: LRU=1 resolved, LRF=2 (MLBW), one L-group, 76 discrete
1583    ///            resonances, resolved region only to 330 eV;
1584    ///   range 1: LRU=2 unresolved (URR), 0 discrete resonances.
1585    /// So `total_resonance_count()` == 76 is **faithful**, not a dropped range —
1586    /// the parser reads every NER range and errors on unconsumed MF2/MT151 data.
1587    /// ENDF/B-VIII.1 later extended the resolved region (RRR to 2554 eV, 565
1588    /// resonances); a low VIII.0 count reflects that evaluation's resolved-region
1589    /// extent, not a parser bug. This test is a regression guard for that fact.
1590    ///
1591    /// Vendored under public-domain ENDF/B redistribution (73-Ta-181 LLNL EVAL,
1592    /// same evaluation NNDC/IAEA distribute) at the workspace-root
1593    /// `tests/data/endf/Ta-181.endf` (Hf-177 precedent). Inside the full NEREIDS
1594    /// workspace — where CI runs — the fixture is always present and the gate
1595    /// asserts fully; a standalone crate build (no workspace fixtures) skips.
1596    #[test]
1597    fn test_parse_ta181_endf8_0_resonance_count() {
1598        let endf_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1599            .parent()
1600            .unwrap()
1601            .parent()
1602            .unwrap()
1603            .join("tests/data/endf/Ta-181.endf");
1604        // The fixture is committed to the repo, so a missing file is a
1605        // packaging/path regression that MUST fail CI — not a skip that would
1606        // silently disable this #638 resonance-count guard.
1607        let endf_text = std::fs::read_to_string(&endf_path).unwrap_or_else(|e| {
1608            panic!(
1609                "vendored Ta-181 regression fixture must be present at \
1610                 {endf_path:?} (committed test data): {e}"
1611            )
1612        });
1613        let data = parse_endf_file2(&endf_text).unwrap();
1614
1615        assert_eq!(data.za, 73181, "Should be Ta-181");
1616        assert!(
1617            (data.awr - 179.3936).abs() < 0.01,
1618            "AWR should be ~179.3936, got {}",
1619            data.awr
1620        );
1621
1622        // Faithful count: VIII.0's RRR is genuinely sparse (76), not a drop.
1623        assert_eq!(
1624            data.total_resonance_count(),
1625            76,
1626            "Ta-181 VIII.0 has 76 discrete resonances (sparse RRR to 330 eV)"
1627        );
1628        assert_eq!(
1629            data.ranges.len(),
1630            2,
1631            "Ta-181 VIII.0 has NER=2 ranges (resolved MLBW + unresolved URR)"
1632        );
1633
1634        // Range 0: resolved MLBW (LRF=2), all 76 resonances live here.
1635        let resolved = &data.ranges[0];
1636        assert!(resolved.resolved, "Range 0 must be resolved (LRU=1)");
1637        assert_eq!(
1638            resolved.formalism,
1639            ResonanceFormalism::MLBW,
1640            "Ta-181 VIII.0 resolved range uses MLBW (LRF=2)"
1641        );
1642        assert_eq!(
1643            resolved.resonance_count(),
1644            76,
1645            "All 76 resonances belong to the resolved MLBW range"
1646        );
1647
1648        // Range 1: unresolved (LRU=2, URR), zero discrete resonances.
1649        let unresolved = &data.ranges[1];
1650        assert!(!unresolved.resolved, "Range 1 must be unresolved (LRU=2)");
1651        assert_eq!(
1652            unresolved.formalism,
1653            ResonanceFormalism::Unresolved,
1654            "Ta-181 VIII.0 second range is the URR (LRU=2)"
1655        );
1656        assert_eq!(
1657            unresolved.resonance_count(),
1658            0,
1659            "URR range carries no discrete resonances"
1660        );
1661
1662        // Mixed evaluation: the resolved MLBW range keeps the file loadable,
1663        // and the skipped URR range must be flagged as unevaluated so callers
1664        // can warn that its span contributes zero cross-section.
1665        assert!(
1666            data.has_unevaluated_ranges(),
1667            "Ta-181's URR range must be flagged as unevaluated"
1668        );
1669        assert_eq!(
1670            data.unevaluated_ranges().len(),
1671            1,
1672            "exactly the URR range is unevaluated"
1673        );
1674    }
1675
1676    /// Assert that a fixture whose every range is a parse-and-skip placeholder
1677    /// is rejected with the no-evaluable-ranges error.
1678    ///
1679    /// This still pins cursor advancement: a misaligned skip surfaces as a
1680    /// *different* error (multi-material guard, unexpected EOF, or a
1681    /// structural-guard message), not this one — so reaching this specific
1682    /// message proves the skip consumed exactly the range body.
1683    #[track_caller]
1684    fn assert_rejected_no_evaluable_ranges(endf: &str) {
1685        let err = parse_endf_file2(endf).unwrap_err();
1686        match err {
1687            EndfParseError::UnsupportedFormat(ref msg) => {
1688                assert!(
1689                    msg.contains("No evaluable resonance ranges"),
1690                    "expected no-evaluable-ranges rejection, got: {msg}"
1691                );
1692            }
1693            other => panic!("expected UnsupportedFormat, got: {other:?}"),
1694        }
1695    }
1696
1697    /// A valid KRM=3 LRF=7 range is parsed-and-skipped, and because it is the
1698    /// file's ONLY range the parse is rejected with the no-evaluable-ranges
1699    /// error (a pure-LRF=7 evaluation would yield zero cross-sections
1700    /// everywhere). The fixture is a minimal but fully valid ENDF MF=2/MT=151
1701    /// block (1 isotope, 1 range, LRF=7, KRM=3, 1 particle pair, 1 spin group,
1702    /// 2 resonances, NCH=1); reaching the no-evaluable-ranges error (rather
1703    /// than a misalignment error) exercises the KRM=3 stride (NCH+2) in the
1704    /// skip's resonance-block consumption.
1705    #[test]
1706    fn test_lrf7_krm3_rejected_no_evaluable_ranges() {
1707        const ENDF: &str =
1708            include_str!("../../../tests/data/synthetic/lrf7_krm3_resonance_column_order.endf");
1709        assert_rejected_no_evaluable_ranges(ENDF);
1710    }
1711
1712    /// The no-evaluable-ranges rejection must NAME each skipped range's
1713    /// formalism and energy span so the user can see exactly what was in the
1714    /// file and why it cannot be evaluated.
1715    #[test]
1716    fn test_lrf7_rejection_names_formalism_and_span() {
1717        const ENDF: &str =
1718            include_str!("../../../tests/data/synthetic/lrf7_krm3_resonance_column_order.endf");
1719        let err = parse_endf_file2(ENDF).unwrap_err();
1720        let msg = err.to_string();
1721        assert!(
1722            msg.contains("LRF=7 (R-Matrix Limited)"),
1723            "rejection must name the skipped formalism, got: {msg}"
1724        );
1725        assert!(
1726            msg.contains("eV"),
1727            "rejection must state the skipped energy span, got: {msg}"
1728        );
1729        assert!(
1730            msg.contains("LRF=1/2/3"),
1731            "rejection must state what NEREIDS evaluates, got: {msg}"
1732        );
1733    }
1734
1735    /// A KRM=2 LRF=7 range with an explicit photon capture channel (IPP=2,
1736    /// MA=0, MT=102) passes every particle-pair, IPP-range, and stride guard
1737    /// during the skip — then the file is rejected because the LRF=7 range is
1738    /// its only range (no evaluable content).
1739    #[test]
1740    fn test_lrf7_krm2_photon_channel_rejected_no_evaluable_ranges() {
1741        // Two particle pairs: pair 1 = n+W184 (MT=2), pair 2 = γ+W185 (MT=102, MA=0);
1742        // one spin group with 2 channels (elastic + photon); one resonance.
1743        const ENDF: &str =
1744            include_str!("../../../tests/data/synthetic/lrf7_krm2_explicit_photon_channel.endf");
1745        assert_rejected_no_evaluable_ranges(ENDF);
1746    }
1747
1748    /// Parse a minimal hand-crafted ENDF snippet with NRO=1 (energy-dependent
1749    /// scattering radius).
1750    ///
1751    /// The fixture encodes:
1752    /// - LRF=3 (Reich-Moore), NRO=1
1753    /// - AP TAB1: 2 points — ENDF values 8.0 and 10.0 (10⁻¹² cm),
1754    ///   which become 80.0 fm and 100.0 fm after ×10 conversion
1755    /// - One L-group (L=0) with one resonance at 6.674 eV
1756    ///
1757    /// Verifies:
1758    /// - ap_table is Some after parsing
1759    /// - ap_table.evaluate(1.0) ≈ 80.0 fm (8.0 × ENDF_RADIUS_TO_FM)
1760    /// - ap_table.evaluate(500.5) ≈ 90.0 fm (midpoint, lin-lin)
1761    /// - ap_table.evaluate(1000.0) ≈ 100.0 fm
1762    /// - scattering_radius_at() delegates to the table
1763    #[test]
1764    fn test_parse_nro1_tab1() {
1765        // Each ENDF line is exactly 80 chars: 66 data chars + 14 MAT/MF/MT/SEQ.
1766        // Cols 67-70: MAT=9237, Cols 71-72: MF=2, Cols 73-75: MT=151, Cols 76-80: seq
1767        //
1768        // Line layout (11 chars per field × 6 fields = 66 chars, then 14 control chars):
1769        //   HEAD:  ZA=92238  AWR=236.006  0  0  NIS=1  0
1770        //   CONT:  ZAI=92238 ABN=1.0      0  LFW=0 NER=1  0
1771        //   CONT:  EL=1e-5   EH=1e4    LRU=1  LRF=3  NRO=1  NAPS=0
1772        //   TAB1 CONT: 0  0  0  0  NR=1  NP=2
1773        //   TAB1 interp: NBT=2, INT=2  (plus 4 padding zeros)
1774        //   TAB1 data:   (1.0, 8.0), (1000.0, 10.0)
1775        //   RM CONT:  SPI=0.0  AP=9.0  0  0  NLS=1  0
1776        //   L CONT:  AWRI=236.006  0  L=0  0  6*NRS=6  NRS=1
1777        //   Resonance: ER=6.674  AJ=0.5  GN=1.493e-3  GG=23e-3  GFA=0  GFB=0
1778        //   SEND: all zeros
1779        // Each ENDF line: 66 data chars + 4-char MAT(9237) + 2-char MF(" 2")
1780        //   + 3-char MT("151") + 5-char SEQ = 80 chars total.
1781        let endf = include_str!("../../../tests/data/synthetic/lrf3_nro1_tab1.endf");
1782
1783        let data = parse_endf_file2(endf).expect("NRO=1 fixture must parse cleanly");
1784        assert_eq!(data.ranges.len(), 1, "one energy range");
1785
1786        let range = &data.ranges[0];
1787        assert_eq!(
1788            range.formalism,
1789            ResonanceFormalism::ReichMoore,
1790            "must be LRF=3"
1791        );
1792
1793        let table = range
1794            .ap_table
1795            .as_ref()
1796            .expect("NRO=1 range must have ap_table");
1797        assert_eq!(table.points.len(), 2, "TAB1 must have 2 points");
1798
1799        // Exact boundary values (ENDF 8.0 × 10 = 80.0 fm).
1800        assert!(
1801            (table.evaluate(1.0) - 80.0).abs() < 1e-10,
1802            "AP(1 eV) = 80.0 fm"
1803        );
1804        assert!(
1805            (table.evaluate(1000.0) - 100.0).abs() < 1e-10,
1806            "AP(1000 eV) = 100.0 fm"
1807        );
1808        // Lin-lin midpoint: AP(500.5 eV) ≈ 90.0 fm.
1809        let mid = table.evaluate(500.5);
1810        assert!((mid - 90.0).abs() < 0.1, "AP midpoint ≈ 90.0 fm, got {mid}");
1811
1812        // scattering_radius_at delegates to the table.
1813        assert!(
1814            (range.scattering_radius_at(1.0) - 80.0).abs() < 1e-10,
1815            "scattering_radius_at(1 eV) = 80.0"
1816        );
1817        assert!(
1818            (range.scattering_radius_at(1000.0) - 100.0).abs() < 1e-10,
1819            "scattering_radius_at(1000 eV) = 100.0"
1820        );
1821
1822        // Resonance is still parsed correctly.
1823        assert_eq!(range.l_groups.len(), 1, "one L-group");
1824        let res = &range.l_groups[0].resonances[0];
1825        assert!((res.energy - 6.674).abs() < 1e-6);
1826    }
1827
1828    /// LFW=1 with LRF=2 (tabulated widths, U-233-style record).
1829    ///
1830    /// SAMMY test tr149 (`t149a.endf`, MAT=9222, ZA=92233) has two ranges:
1831    ///   - Range 0: LRU=1 (resolved, Reich-Moore / LRF=3)
1832    ///   - Range 1: LRU=2, LRF=2, **LFW=1** (energy-dependent fission widths)
1833    ///
1834    /// ENDF-6 §2.2.2.2: for LFW=1/LRF=2 the per-(L,J) LIST layout is
1835    /// **identical to LFW=0/LRF=2** (the fission widths are already
1836    /// per-energy-point in the LIST tail), so the parser dispatches to the
1837    /// shared `skip_urr_range` path: the URR body is parsed-and-skipped
1838    /// (structural guards applied, values discarded) and the range is
1839    /// stored as a non-evaluable placeholder.
1840    ///
1841    /// We previously gated this assertion on a `../SAMMY/...t149a.endf`
1842    /// sibling checkout; on CI (and on any clean clone) the file was
1843    /// absent and the test silently reported `ok` after a `Skipping…`
1844    /// print. Vendoring the full tr149 ENDF would be heavy; instead we
1845    /// synthesise a minimal but record-shape-faithful LFW=1/LRF=2
1846    /// fixture so the assertion runs unconditionally.
1847    #[test]
1848    fn test_parse_u233_lfw1_lrf2_urr_parsed() {
1849        // Minimal MF=2/MT=151 with two ranges, mirroring tr149 layout:
1850        //   Range 0: LRU=1, LRF=3 (Reich-Moore) — one trivial resonance.
1851        //   Range 1: LRU=2, LRF=2, LFW=1       — NLS=1, NJS=1, NE=2.
1852        // LFW=1 is flagged on the isotope CONT (L2 field).
1853        const ENDF: &str = include_str!("../../../tests/data/synthetic/u233_lfw1_lrf2_urr.endf");
1854
1855        let data = parse_endf_file2(ENDF)
1856            .expect("U-233 LFW=1/LRF=2 fixture must parse (record layout = LFW=0/LRF=2)");
1857
1858        // Both ranges must round-trip: resolved + URR.
1859        assert_eq!(data.ranges.len(), 2, "must have 2 ranges (resolved + URR)");
1860
1861        let resolved_count = data.ranges.iter().filter(|r| r.resolved).count();
1862        assert_eq!(resolved_count, 1, "exactly one resolved range");
1863
1864        // The URR range is parsed-and-skipped: it must appear as a
1865        // non-resolved Unresolved range. LFW=1/LRF=2 shares the LFW=0/LRF=2
1866        // record layout, so a mis-sized skip would trip the multi-material
1867        // guard or drop the resolved range — the counts above pin alignment.
1868        let urr_count = data
1869            .ranges
1870            .iter()
1871            .filter(|r| r.formalism == ResonanceFormalism::Unresolved)
1872            .count();
1873        assert_eq!(
1874            urr_count, 1,
1875            "LFW=1/LRF=2 URR range must be present (skipped)"
1876        );
1877        let urr_range = data
1878            .ranges
1879            .iter()
1880            .find(|r| r.formalism == ResonanceFormalism::Unresolved)
1881            .expect("URR range must exist");
1882        assert!(!urr_range.resolved, "URR range must not be resolved");
1883
1884        // Mixed evaluation: the resolved range keeps the file loadable, and
1885        // the skipped URR range must be flagged as unevaluated.
1886        assert!(data.has_unevaluated_ranges());
1887        assert_eq!(data.unevaluated_ranges().len(), 1);
1888    }
1889
1890    /// Hand-crafted LRF=1 URR roundtrip test.
1891    ///
1892    /// Verifies that a minimal synthetic ENDF snippet with LRU=2, LRF=1 is
1893    /// parsed correctly: one L-group (L=0), two J-groups with known D, AJ,
1894    /// AMUN, GNO, GG, GF values.
1895    #[test]
1896    fn test_parse_lrf1_urr_roundtrip() {
1897        // Minimal ENDF MF=2/MT=151 with one resolved range followed by one
1898        // LRU=2 LRF=1 unresolved range.
1899        //
1900        // Resolved range: a simple RM LRF=3 with one resonance (gives the
1901        // parser something valid to consume before the URR section).
1902        //
1903        // URR range: LRU=2, LRF=1, NLS=1 (L=0), NJS=2 J-groups.
1904        //   J=2.0: D=0.5 eV, AMUN=1, GNO=3e-4 eV, GG=3.5e-2 eV, GF=0
1905        //   J=3.0: D=0.4 eV, AMUN=1, GNO=2e-4 eV, GG=3.0e-2 eV, GF=1e-3 eV
1906        //
1907        // Each ENDF line: 66 data chars + MAT(4) MF(2) MT(3) SEQ(5) = 80 chars.
1908        const ENDF: &str = include_str!("../../../tests/data/synthetic/lrf1_urr_roundtrip.endf");
1909
1910        let data = parse_endf_file2(ENDF).expect("LRF=1 URR fixture must parse cleanly");
1911
1912        // Should have 2 ranges: one resolved + one URR.
1913        assert_eq!(data.ranges.len(), 2, "must have 2 ranges");
1914
1915        let urr_range = &data.ranges[1];
1916        assert!(!urr_range.resolved, "URR range must not be resolved");
1917        assert_eq!(
1918            urr_range.formalism,
1919            ResonanceFormalism::Unresolved,
1920            "formalism must be Unresolved"
1921        );
1922
1923        // The LRF=1 URR body is parsed-and-skipped; the range is present as
1924        // Unresolved (asserted above). ranges.len()==2 confirms the skip
1925        // consumed exactly the URR body and left the line stream aligned.
1926
1927        // Mixed evaluation: the resolved range keeps the file loadable, and
1928        // the skipped URR range must be flagged as unevaluated.
1929        assert!(data.has_unevaluated_ranges());
1930        assert_eq!(data.unevaluated_ranges().len(), 1);
1931    }
1932
1933    /// An LRF=2 URR range with INT=3 (log-x/lin-y) passes the per-J INT guard
1934    /// (§0.5: INT∈1..=5 — a stricter guard would spuriously reject valid
1935    /// evaluations), then the file is rejected because the URR range is its
1936    /// only range (no evaluable content).
1937    #[test]
1938    fn test_parse_lrf2_urr_int3_rejected_no_evaluable_ranges() {
1939        // Minimal ENDF MF=2/MT=151 with one LRU=2/LRF=2 range:
1940        // NLS=1 (L=0), NJS=1, NE=2 energy points, INT=3 (log-x/lin-y).
1941        // LIST layout: row 0 = [0, 0, 0, AMUN, 0, AMUF]; rows 1..=NE = (E,
1942        // D, GX, GN, GG, GF). Total = 6*(NE+1) = 18 floats = 3 lines.
1943        // Reaching the no-evaluable-ranges error (not an INT rejection or a
1944        // misalignment error) proves the INT=3 guard accepted the record and
1945        // the skip consumed exactly the URR body.
1946        const ENDF: &str =
1947            include_str!("../../../tests/data/synthetic/lrf2_urr_int3_roundtrip.endf");
1948        assert_rejected_no_evaluable_ranges(ENDF);
1949    }
1950
1951    /// LRF=2 URR with INT=0 is rejected as a hard error.
1952    ///
1953    /// ENDF-6 §0.5 defines INT codes 1..=5 only. INT=0 is malformed,
1954    /// not merely unsupported, so the parser surfaces it as
1955    /// `UnsupportedFormat("INT=0 out of spec (expected 1..=5)")` rather
1956    /// than panicking or silently defaulting to lin-lin.
1957    #[test]
1958    fn test_parse_lrf2_urr_int0_rejected() {
1959        // Same skeleton as INT=3 test, but with INT=0 in the J CONT.
1960        const ENDF: &str =
1961            include_str!("../../../tests/data/synthetic/lrf2_urr_int0_rejected.endf");
1962
1963        let err = parse_endf_file2(ENDF).unwrap_err();
1964        // Match on the error variant structurally so the test only
1965        // depends on the parser surfacing `UnsupportedFormat`, not on
1966        // the exact `Display` wording of `EndfParseError`.  Then check
1967        // the payload string for the diagnostic substrings.
1968        match err {
1969            EndfParseError::UnsupportedFormat(ref msg) => {
1970                assert!(
1971                    msg.contains("INT=0") && msg.contains("out of spec"),
1972                    "expected INT-out-of-spec rejection in UnsupportedFormat payload, \
1973                     got: {msg}"
1974                );
1975            }
1976            other => panic!("expected EndfParseError::UnsupportedFormat for INT=0, got: {other:?}"),
1977        }
1978    }
1979
1980    // -----------------------------------------------------------------------
1981    // Issue #123: robustness tests for malformed input validation
1982    // -----------------------------------------------------------------------
1983
1984    /// `checked_count` rejects negative values.
1985    #[test]
1986    fn test_checked_count_negative() {
1987        let err = checked_count(-1, "NLS").unwrap_err();
1988        assert!(
1989            err.to_string().contains("Negative"),
1990            "expected negative error, got: {err}"
1991        );
1992    }
1993
1994    /// `checked_count` rejects values above `MAX_ENDF_COUNT` to prevent
1995    /// allocation bombs from malformed files.
1996    #[test]
1997    fn test_checked_count_upper_bound() {
1998        // Just above the limit.
1999        let err = checked_count(MAX_ENDF_COUNT + 1, "NRS").unwrap_err();
2000        assert!(
2001            err.to_string().contains("too large"),
2002            "expected upper-bound error, got: {err}"
2003        );
2004
2005        // At the limit: should succeed.
2006        assert_eq!(checked_count(MAX_ENDF_COUNT, "NRS").unwrap(), 1_000_000);
2007
2008        // i32::MAX: should be rejected.
2009        let err = checked_count(i32::MAX, "NPL").unwrap_err();
2010        assert!(
2011            err.to_string().contains("too large"),
2012            "expected upper-bound error for i32::MAX, got: {err}"
2013        );
2014    }
2015
2016    /// `parse_endf_int` rejects non-integral float values rather than
2017    /// silently truncating.
2018    ///
2019    /// Without the strict check, an INT-field value stored as
2020    /// `"1.900000+0"` would be cast as `1.9_f64 as i32 == 1`, masking
2021    /// a malformed evaluation.  After the strict check, the parser
2022    /// surfaces `InvalidNumber` immediately.
2023    ///
2024    /// The two field layouts:
2025    ///   • field 0: integral float "1.000000+0" → returns Ok(1)
2026    ///   • field 1: non-integral  "1.900000+0" → returns Err(InvalidNumber)
2027    /// must both round-trip the standard ENDF integer-as-float encoding.
2028    #[test]
2029    fn test_parse_endf_int_rejects_non_integral_float() {
2030        // 11-char fields, padded with leading space to match the ENDF column
2031        // width.  Field 0 is integral; field 1 is non-integral.
2032        const LINE: &str = " 1.000000+0 1.900000+0";
2033
2034        // Integral float must parse cleanly.
2035        let ok = parse_endf_int(LINE, 0).expect("integral float must parse");
2036        assert_eq!(ok, 1);
2037
2038        // Non-integral float must be rejected, not truncated to 1.
2039        let err = parse_endf_int(LINE, 1).expect_err("non-integral must be rejected");
2040        match err {
2041            EndfParseError::InvalidNumber(ref msg) => {
2042                assert!(
2043                    msg.contains("Non-integral"),
2044                    "expected Non-integral diagnostic, got: {msg}"
2045                );
2046            }
2047            other => panic!("expected InvalidNumber, got: {other:?}"),
2048        }
2049    }
2050
2051    /// Negative L-value in a Breit-Wigner range is rejected.
2052    ///
2053    /// Constructs a minimal SLBW fixture with L=-1 in the L-group CONT record.
2054    /// Without the validation, `l_cont.l1 as u32` would wrap to `u32::MAX`.
2055    #[test]
2056    fn test_bw_negative_l_rejected() {
2057        // Minimal SLBW fixture: HEAD + isotope CONT + range CONT + SPI/AP CONT +
2058        // L-group CONT with L=-1 (field 3 = -1).
2059        const ENDF: &str =
2060            include_str!("../../../tests/data/synthetic/lrf1_bw_negative_l_rejected.endf");
2061
2062        let err = parse_endf_file2(ENDF).unwrap_err();
2063        assert!(
2064            err.to_string().contains("negative L"),
2065            "expected negative L error, got: {err}"
2066        );
2067    }
2068
2069    /// Negative L-value in a Reich-Moore range is rejected.
2070    #[test]
2071    fn test_rm_negative_l_rejected() {
2072        const ENDF: &str =
2073            include_str!("../../../tests/data/synthetic/lrf3_rm_negative_l_rejected.endf");
2074
2075        let err = parse_endf_file2(ENDF).unwrap_err();
2076        assert!(
2077            err.to_string().contains("negative L"),
2078            "expected negative L error, got: {err}"
2079        );
2080    }
2081
2082    /// LRU=0 range with non-zero NLS is rejected.
2083    ///
2084    /// ENDF-6 §2.2 says the SPI/AP CONT after an LRU=0 range must have
2085    /// NLS=0 (no L-groups for scattering-radius-only ranges).
2086    #[test]
2087    fn test_lru0_nonzero_nls_rejected() {
2088        const ENDF: &str =
2089            include_str!("../../../tests/data/synthetic/lru0_nonzero_nls_rejected.endf");
2090
2091        let err = parse_endf_file2(ENDF).unwrap_err();
2092        assert!(
2093            err.to_string().contains("NLS=3"),
2094            "expected LRU=0 NLS validation error, got: {err}"
2095        );
2096    }
2097
2098    /// A resolved (LRU=1) MLBW range with NLS=0 is rejected.
2099    ///
2100    /// ENDF-6 §2.2.1.1 requires NLS >= 1 in a resolved range (NLS=0 is
2101    /// reserved for LRU=0 scattering-radius-only stanzas). Without the guard
2102    /// this file loads as "evaluable" with zero resonances — zero
2103    /// cross-section everywhere and no warning.
2104    #[test]
2105    fn test_mlbw_nls0_rejected() {
2106        const ENDF: &str = include_str!("../../../tests/data/synthetic/mlbw_nls0_rejected.endf");
2107
2108        let err = parse_endf_file2(ENDF).unwrap_err();
2109        assert!(
2110            err.to_string().contains("NLS=0"),
2111            "expected resolved NLS=0 rejection, got: {err}"
2112        );
2113    }
2114
2115    /// A file whose ONLY range is resolved with NLS=1 but NRS=0 in every
2116    /// L-group is rejected by the no-evaluable-content guard, naming the
2117    /// inert span. (The range itself is accepted as a warn-and-skip
2118    /// placeholder — SAMMY/OpenScale accept NRS=0 L-groups — so the
2119    /// rejection comes from the file-level guard, not a per-range error.)
2120    #[test]
2121    fn test_mlbw_nrs0_everywhere_rejected() {
2122        const ENDF: &str =
2123            include_str!("../../../tests/data/synthetic/mlbw_nrs0_everywhere_rejected.endf");
2124
2125        let err = parse_endf_file2(ENDF).unwrap_err();
2126        let msg = err.to_string();
2127        assert!(
2128            msg.contains("No evaluable resonance ranges"),
2129            "expected no-evaluable-content rejection, got: {msg}"
2130        );
2131        assert!(
2132            msg.contains("resolved range with no resonances"),
2133            "rejection must name the inert resolved span, got: {msg}"
2134        );
2135    }
2136
2137    /// A mixed file [populated resolved range][all-empty resolved range]
2138    /// loads: the populated range stays evaluable, the inert range becomes a
2139    /// warn-and-skip placeholder — symmetric with LRF=7/LRU=2/LRU=0 handling,
2140    /// so the good range is not lost.
2141    #[test]
2142    fn test_mlbw_nrs0_plus_resolved_mixed_loads_and_flags() {
2143        const ENDF: &str =
2144            include_str!("../../../tests/data/synthetic/mlbw_nrs0_plus_resolved_mixed.endf");
2145
2146        let data = parse_endf_file2(ENDF).expect("mixed populated + inert file must load");
2147        assert_eq!(data.ranges.len(), 2);
2148        assert!(data.has_evaluable_range());
2149        assert!(data.has_unevaluated_ranges());
2150        assert_eq!(data.total_resonance_count(), 1);
2151        let inert = data
2152            .ranges
2153            .iter()
2154            .find(|r| !r.is_evaluable())
2155            .expect("the inert range must be present");
2156        assert!(
2157            inert
2158                .skip_description()
2159                .contains("LRF=2 (MLBW) resolved range with no resonances"),
2160            "skip_description must name the inert shape, got: {}",
2161            inert.skip_description()
2162        );
2163    }
2164
2165    /// A resolved (LRU=1) Reich-Moore range with NLS=0 is rejected.
2166    #[test]
2167    fn test_rm_nls0_rejected() {
2168        const ENDF: &str = include_str!("../../../tests/data/synthetic/lrf3_rm_nls0_rejected.endf");
2169
2170        let err = parse_endf_file2(ENDF).unwrap_err();
2171        assert!(
2172            err.to_string().contains("NLS=0"),
2173            "expected resolved NLS=0 rejection, got: {err}"
2174        );
2175    }
2176
2177    /// LFW=1/LRF=1 (energy-dependent fission widths) URR is skipped with the
2178    /// line stream left aligned.
2179    ///
2180    /// ENDF-6 §2.2.2.1 Case B: a shared NE-point energy grid is followed,
2181    /// for each (L, J), by a full LIST record — a control line
2182    /// `[0.0, 0.0, L, MUF, NE+6, 0]` and then a body
2183    /// `[D, AJ, AMUN, GNO, GG, 0] + GF(1..NE)`.  The per-J control line MUST
2184    /// be consumed before the body; otherwise the line stream misaligns by
2185    /// one record per J-group and the file fails to parse (the stray lines
2186    /// trip the multi-material guard).
2187    ///
2188    /// This fixture is standards-compliant (it includes the per-J control
2189    /// line); the assertion that it parses end-to-end is the regression guard
2190    /// for the Case-B skip's cursor advancement.
2191    ///
2192    /// The fixture has NE=2, NLS=1, NJS=1, MUF=1.
2193    #[test]
2194    fn test_lfw1_lrf1_urr_rejected_no_evaluable_ranges() {
2195        const ENDF: &str =
2196            include_str!("../../../tests/data/synthetic/lfw1_urr_gracefully_skipped.endf");
2197
2198        // The Case-B URR body is parsed-and-skipped; consuming the per-J
2199        // control line (N1=NE+6) keeps the stream aligned. The fixture's only
2200        // range is the URR, so the aligned parse then hits the
2201        // no-evaluable-ranges rejection — a misaligned skip would instead trip
2202        // the multi-material guard or EOF (see the fixture note above), so
2203        // reaching this specific error pins the cursor advancement.
2204        assert_rejected_no_evaluable_ranges(ENDF);
2205    }
2206
2207    /// A per-J LIST control whose `N1 != NE+6` is a malformed Case-B record.
2208    /// The parser must reject it — this covers the per-J N1 validation guard
2209    /// (the SCALE `list.getN1()-6 == ener.getNtot()` relation). The fixture is
2210    /// byte-identical to the valid one except the per-J control N1 (8 -> 7).
2211    #[test]
2212    fn test_lfw1_lrf1_urr_rejects_bad_perj_n1() {
2213        const ENDF: &str = include_str!("../../../tests/data/synthetic/lfw1_urr_bad_perj_n1.endf");
2214
2215        let err = parse_endf_file2(ENDF).unwrap_err();
2216        assert!(
2217            err.to_string().contains("N1=") && err.to_string().contains("NE+6"),
2218            "expected per-J N1 != NE+6 rejection, got: {err}"
2219        );
2220    }
2221
2222    /// An LRU=2 (URR) range with LRF ∉ {1, 2} is a malformed record and is
2223    /// rejected with an error naming the offending LRF.
2224    ///
2225    /// ENDF-6 §2.2.2 restricts the unresolved region to LRF=1 or LRF=2. The
2226    /// parser previously heuristically consumed such a body and dropped the
2227    /// span silently; it now hard-errors like the sibling malformed-record
2228    /// guards.
2229    #[test]
2230    fn test_urr_lrf3_rejected() {
2231        const ENDF: &str = include_str!("../../../tests/data/synthetic/urr_lrf3_rejected.endf");
2232
2233        let err = parse_endf_file2(ENDF).unwrap_err();
2234        let msg = err.to_string();
2235        assert!(
2236            msg.contains("LRF=3") && msg.contains("URR"),
2237            "expected URR LRF=3 rejection naming the LRF, got: {msg}"
2238        );
2239    }
2240
2241    /// LRU=0 range with non-zero L1 in SPI/AP CONT is rejected.
2242    ///
2243    /// ENDF-6 §2.2: the SPI/AP CONT record for LRU=0 must be
2244    /// [SPI, AP, 0, 0, NLS=0, 0].  Non-zero L1 or L2 indicates a
2245    /// malformed or mis-identified record.
2246    #[test]
2247    fn test_lru0_nonzero_l1_rejected() {
2248        const ENDF: &str =
2249            include_str!("../../../tests/data/synthetic/lru0_nonzero_l1_rejected.endf");
2250
2251        let err = parse_endf_file2(ENDF).unwrap_err();
2252        assert!(
2253            err.to_string().contains("L1=5"),
2254            "expected LRU=0 L1 validation error, got: {err}"
2255        );
2256    }
2257
2258    /// A well-formed LRU=0-only evaluation (scattering radius, no resonance
2259    /// parameters) is rejected — NEREIDS cannot evaluate it and loading it
2260    /// would yield silent zero cross-sections — but the message NAMES the
2261    /// LRU=0 span rather than misreporting an empty file.
2262    ///
2263    /// ENDF-6 §2.1: LRU=0 gives a scattering radius but no resonances. The
2264    /// fixture is byte-identical to `lru0_nonzero_nls_rejected` except the
2265    /// SPI/AP CONT carries NLS=0 (well-formed), so parsing reaches the
2266    /// no-evaluable-content guard rather than the NLS validation guard.
2267    #[test]
2268    fn test_lru0_only_rejected_names_lru0_span() {
2269        const ENDF: &str =
2270            include_str!("../../../tests/data/synthetic/lru0_only_rejected_no_evaluable.endf");
2271
2272        let err = parse_endf_file2(ENDF).unwrap_err();
2273        let msg = err.to_string();
2274        assert!(
2275            msg.contains("No evaluable resonance ranges"),
2276            "expected no-evaluable-ranges rejection, got: {msg}"
2277        );
2278        assert!(
2279            msg.contains("LRU=0 (scattering-radius-only, no resonance parameters)"),
2280            "rejection must name the LRU=0 span, got: {msg}"
2281        );
2282    }
2283
2284    /// A mixed evaluation carrying an LRU=0 range plus a resolved range still
2285    /// loads: the resolved range keeps the file evaluable, and the LRU=0 span
2286    /// is surfaced as a non-evaluable parse-and-skip placeholder (same handling
2287    /// as LRF=7 / LRU=2).
2288    #[test]
2289    fn test_lru0_plus_resolved_mixed_loads_and_flags_lru0() {
2290        const ENDF: &str =
2291            include_str!("../../../tests/data/synthetic/lru0_plus_resolved_mixed.endf");
2292
2293        let data = parse_endf_file2(ENDF).expect("mixed LRU=0 + resolved file must load");
2294        assert_eq!(data.ranges.len(), 2, "LRU=0 range + resolved range");
2295        assert!(
2296            data.has_evaluable_range(),
2297            "resolved range keeps it evaluable"
2298        );
2299        assert!(data.has_unevaluated_ranges(), "LRU=0 range is unevaluated");
2300
2301        let lru0 = data
2302            .ranges
2303            .iter()
2304            .find(|r| r.formalism == ResonanceFormalism::ScatteringRadiusOnly)
2305            .expect("the LRU=0 range must be present");
2306        assert!(!lru0.resolved, "LRU=0 range must not be resolved");
2307        assert!(
2308            lru0.skip_description()
2309                .contains("LRU=0 (scattering-radius-only, no resonance parameters)"),
2310            "LRU=0 skip_description must name the span, got: {}",
2311            lru0.skip_description()
2312        );
2313
2314        // The fixture gives the placeholder DIFFERENT SPI/AP (1.5 / 5.0 fm)
2315        // than the resolved range (2.5 / 9.6931 fm), so consumers that must
2316        // prefer the evaluable range are distinguishable from ones reading
2317        // the placeholder.
2318        assert_eq!(lru0.target_spin, 1.5);
2319        assert_eq!(lru0.scattering_radius, 5.0);
2320        let resolved = data
2321            .ranges
2322            .iter()
2323            .find(|r| r.is_evaluable())
2324            .expect("the resolved range must be evaluable");
2325        assert_eq!(resolved.target_spin, 2.5);
2326        assert!((resolved.scattering_radius - 9.6931).abs() < 1e-9);
2327    }
2328
2329    /// N1 != 6*NRS in a BW range CONT is rejected.
2330    #[test]
2331    fn test_bw_n1_nrs_mismatch_rejected() {
2332        const ENDF: &str =
2333            include_str!("../../../tests/data/synthetic/lrf1_bw_n1_nrs_mismatch_rejected.endf");
2334
2335        let err = parse_endf_file2(ENDF).unwrap_err();
2336        assert!(
2337            err.to_string().contains("N1=7"),
2338            "expected N1/NRS mismatch error, got: {err}"
2339        );
2340    }
2341
2342    /// Multi-MAT detection: unconsumed MF=2/MT=151 lines after the first
2343    /// material are rejected.
2344    #[test]
2345    fn test_multi_mat_detection() {
2346        // A valid single-range SLBW file with an extra trailing data line
2347        // that still carries MF=2/MT=151 tags.
2348        const ENDF: &str = include_str!("../../../tests/data/synthetic/multi_mat_detection.endf");
2349
2350        let err = parse_endf_file2(ENDF).unwrap_err();
2351        assert!(
2352            err.to_string().contains("Multiple materials"),
2353            "expected multi-MAT error, got: {err}"
2354        );
2355    }
2356
2357    /// MF=2 NIS>1 multi-isotope materials are rejected.
2358    ///
2359    /// ENDF-6 §2.1 allows a single material to carry several isotopes, each
2360    /// with its own ZAI/ABN/NER subsection. NEREIDS's `ResonanceData` cannot
2361    /// represent that hierarchy without losing per-isotope abundance weights;
2362    /// rather than silently flatten the ranges into one isotope, the parser
2363    /// returns `UnsupportedFormat` so a downstream consumer cannot be tricked
2364    /// into computing an abundance-blind cross section.
2365    ///
2366    /// The synthetic fixture is a minimal two-isotope SLBW material: Cu-63
2367    /// (ZAI=29063, ABN=0.6917) and Cu-65 (ZAI=29065, ABN=0.3083), each with
2368    /// a single L=0 resonance. The parser should reject the file as soon as
2369    /// it reads the HEAD record (NIS=2 > 1) and never advance into either
2370    /// isotope subsection.
2371    #[test]
2372    fn test_parse_endf_rejects_nis_gt_1() {
2373        // Minimal NIS=2 fixture. Both isotope subsections use LRF=1 SLBW so
2374        // that, if the NIS guard were ever removed, the parser would have a
2375        // valid stream to walk and the assertion below would still pin the
2376        // guard. The HEAD's ZA is set to the natural-element identifier
2377        // ZA=29000 (nat-Cu); for NIS=1 callers that would be rejected by
2378        // `isotope_from_za` with an "A=0" error, but the NIS=2 check runs
2379        // first and returns the expected UnsupportedFormat.
2380        const ENDF: &str = include_str!("../../../tests/data/synthetic/nis_gt_1_rejected.endf");
2381
2382        let err = parse_endf_file2(ENDF).unwrap_err();
2383        match &err {
2384            EndfParseError::UnsupportedFormat(msg) => {
2385                assert!(
2386                    msg.contains("NIS=2"),
2387                    "expected NIS=2 in error message, got: {msg}"
2388                );
2389                assert!(
2390                    msg.contains("multi-isotope"),
2391                    "expected 'multi-isotope' in error message, got: {msg}"
2392                );
2393            }
2394            other => panic!("expected UnsupportedFormat for NIS>1, got {other:?}"),
2395        }
2396    }
2397
2398    /// LRF=7 resonance LIST carries NRS in L2 and NX (packed-row count) in N2.
2399    ///
2400    /// ENDF-6 §2.2.1.6: the resonance LIST control record is
2401    /// `[C1=0, C2=0, L1=0, L2=NRS, N1=6*NX, N2=NX]`. For spin groups whose
2402    /// per-resonance row fits in one 6-float ENDF line (NCH+1 ≤ 6 for KRM=2,
2403    /// NCH+2 ≤ 6 for KRM=3), NX numerically equals NRS, so the field
2404    /// confusion is invisible. This fixture stresses the case where the
2405    /// per-resonance row requires *more than one* 6-float row, giving
2406    /// NX > NRS and L2 ≠ N2.
2407    ///
2408    /// Construction: KRM=3, NCH=5 (5 elastic channels in a single spin
2409    /// group), NRS=2.
2410    ///   per-resonance values = NCH+2 = 7 → 2 packed rows of 6 floats (12
2411    ///     values per resonance, last 5 are padding zeros).
2412    ///   NX = NRS · 2 = 4 packed rows.
2413    ///   NPL = 6·NX = 24 floats.
2414    ///   resonance LIST control = `[0.0, 0.0, 0, NRS=2, 24, NX=4]`.
2415    ///
2416    /// Under the (pre-fix) buggy reader that took NRS from N2, this fixture
2417    /// would set NRS=4, recompute stride = NPL/NRS = 6, and trip the
2418    /// min_stride guard (6 < NCH+2 = 7) with a misleading
2419    /// "stride too small" UnsupportedFormat error. With the fix the skip reads
2420    /// NRS=2 (from L2), so stride = NPL/NRS = 24/2 = 12 ≥ NCH+2 = 7 and the
2421    /// range parses end-to-end (loading as a skipped RMatrixLimited range).
2422    #[test]
2423    fn test_parse_lrf7_l2_holds_nrs_with_nx_neq_nrs() {
2424        const ENDF: &str =
2425            include_str!("../../../tests/data/synthetic/lrf7_l2_holds_nrs_with_nx_neq_nrs.endf");
2426
2427        // Reading NRS from L2 keeps the stride guard satisfied, so the skip
2428        // consumes the wide-spin-group range cleanly and the parse reaches the
2429        // no-evaluable-ranges rejection (the LRF=7 range is the file's only
2430        // range) instead of the misleading "stride too small" error the
2431        // NRS-from-N2 misread would produce.
2432        assert_rejected_no_evaluable_ranges(ENDF);
2433    }
2434
2435    /// LRF=7 spin group with nonzero KBK (R-external background) is rejected.
2436    ///
2437    /// The ENDF-6 §2.2.1.6 manual prose treats KBK as a nonzero flag with NCH
2438    /// background records; OpenScale's reference reader
2439    /// (File2.cpp:444-524) treats KBK as a sparse record count with each
2440    /// subrecord carrying the channel index in L1 and the LBK formalism flag
2441    /// in L2. The two conventions disagree on loop bound, per-subrecord
2442    /// control-field positions, and payload shape per LBK value. No local
2443    /// ENDF/B-VIII.0 evaluation has nonzero KBK to validate against.
2444    /// Until a policy decision resolves the dispute, NEREIDS hard-rejects
2445    /// nonzero KBK so the parser cannot silently misalign the stream past
2446    /// the offending spin group.
2447    #[test]
2448    fn test_parse_lrf7_rejects_nonzero_kbk() {
2449        const ENDF: &str =
2450            include_str!("../../../tests/data/synthetic/lrf7_nonzero_kbk_rejected.endf");
2451
2452        let err = parse_endf_file2(ENDF).unwrap_err();
2453        match &err {
2454            EndfParseError::UnsupportedFormat(msg) => {
2455                assert!(
2456                    msg.contains("KBK=1"),
2457                    "expected KBK=1 in error message, got: {msg}"
2458                );
2459            }
2460            other => panic!("expected UnsupportedFormat for KBK != 0, got {other:?}"),
2461        }
2462    }
2463
2464    /// LRF=7 spin group with nonzero KPS (tabulated phase-shift override) is
2465    /// rejected. Same documentation-vs-implementation dispute as KBK;
2466    /// OpenScale itself refuses to read KPS > 0
2467    /// (File2.cpp:439-441 throws "kps > 0 for lrf=7 not yet supported"), so
2468    /// NEREIDS adopts the same behaviour rather than guess at a layout.
2469    #[test]
2470    fn test_parse_lrf7_rejects_nonzero_kps() {
2471        const ENDF: &str =
2472            include_str!("../../../tests/data/synthetic/lrf7_nonzero_kps_rejected.endf");
2473
2474        let err = parse_endf_file2(ENDF).unwrap_err();
2475        match &err {
2476            EndfParseError::UnsupportedFormat(msg) => {
2477                assert!(
2478                    msg.contains("KPS=1"),
2479                    "expected KPS=1 in error message, got: {msg}"
2480                );
2481            }
2482            other => panic!("expected UnsupportedFormat for KPS != 0, got {other:?}"),
2483        }
2484    }
2485
2486    /// LRF=7 particle pair with PNT=2 ("ASSIGN") is rejected at parse time.
2487    /// SAMMY's Check_Quantum (rml/mrml03.f:22) rejects Lpent outside {0,1};
2488    /// neither SAMMY nor NEREIDS implements PNT=2.  Validating up front keeps
2489    /// the unknown penetrability flag out of the physics evaluator.
2490    #[test]
2491    fn test_parse_lrf7_rejects_pnt_two() {
2492        const ENDF: &str = include_str!("../../../tests/data/synthetic/lrf7_pnt2_rejected.endf");
2493
2494        let err = parse_endf_file2(ENDF).unwrap_err();
2495        match &err {
2496            EndfParseError::UnsupportedFormat(msg) => {
2497                assert!(
2498                    msg.contains("PNT=2"),
2499                    "expected PNT=2 in error message, got: {msg}"
2500                );
2501            }
2502            other => panic!("expected UnsupportedFormat for PNT=2, got {other:?}"),
2503        }
2504    }
2505
2506    /// LRF=7 massless particle pair (MA=0, photon/eliminated channel) declared
2507    /// with PNT=1 is rejected: SAMMY always assigns Lpent=0 to the photon
2508    /// channel, and the physics evaluator's PNT=1 penetrability branch would
2509    /// otherwise divide by a zero reduced mass.
2510    #[test]
2511    fn test_parse_lrf7_rejects_massless_pnt_one() {
2512        const ENDF: &str =
2513            include_str!("../../../tests/data/synthetic/lrf7_massless_pnt1_rejected.endf");
2514
2515        let err = parse_endf_file2(ENDF).unwrap_err();
2516        match &err {
2517            EndfParseError::UnsupportedFormat(msg) => {
2518                assert!(
2519                    msg.contains("must have PNT=0"),
2520                    "expected massless-PNT consistency message, got: {msg}"
2521                );
2522            }
2523            other => panic!("expected UnsupportedFormat for massless PNT=1, got {other:?}"),
2524        }
2525    }
2526
2527    /// LRF=7 particle pair with a fractional PNT (1.5) is rejected before the
2528    /// f64→i32 narrowing can truncate it into a spurious 0/1 that would bypass
2529    /// the {0,1} range check.
2530    #[test]
2531    fn test_parse_lrf7_rejects_fractional_pnt() {
2532        const ENDF: &str =
2533            include_str!("../../../tests/data/synthetic/lrf7_pnt_fractional_rejected.endf");
2534
2535        let err = parse_endf_file2(ENDF).unwrap_err();
2536        match &err {
2537            EndfParseError::UnsupportedFormat(msg) => {
2538                assert!(
2539                    msg.contains("PNT=1.5") && msg.contains("not a finite integer"),
2540                    "expected fractional-PNT message, got: {msg}"
2541                );
2542            }
2543            other => panic!("expected UnsupportedFormat for fractional PNT, got {other:?}"),
2544        }
2545    }
2546
2547    /// LRF=7 PNT=1 pair with a non-positive mass (MB=0) is rejected up front:
2548    /// the penetrability path would otherwise form a non-finite reduced mass.
2549    #[test]
2550    fn test_parse_lrf7_rejects_pnt1_zero_mass() {
2551        const ENDF: &str =
2552            include_str!("../../../tests/data/synthetic/lrf7_pnt1_zero_mass_rejected.endf");
2553
2554        let err = parse_endf_file2(ENDF).unwrap_err();
2555        match &err {
2556            EndfParseError::UnsupportedFormat(msg) => {
2557                assert!(
2558                    msg.contains("finite positive masses"),
2559                    "expected PNT=1 mass-validation message, got: {msg}"
2560                );
2561            }
2562            other => panic!("expected UnsupportedFormat for PNT=1 zero mass, got {other:?}"),
2563        }
2564    }
2565
2566    /// MF=2 NIS=0 (no isotopes declared) is rejected up-front.
2567    ///
2568    /// ENDF-6 §2.1 requires NIS >= 1 for a valid resonance evaluation.
2569    /// Without the explicit reject, NIS=0 would fall through the per-isotope
2570    /// loop (zero iterations), leave the resonance section empty, and trip a
2571    /// confusing downstream "unconsumed data lines" / empty-range failure
2572    /// far from the actual root cause. The reject mirrors the NIS>1 guard
2573    /// pattern so both invalid extremes return a clear UnsupportedFormat.
2574    #[test]
2575    fn test_parse_endf_rejects_nis_zero() {
2576        // Minimal NIS=0 fixture: just the HEAD line with NIS=0. The HEAD's
2577        // ZA=74184 (W-184) is a valid identifier, so any error must come
2578        // from the NIS=0 guard, not from `isotope_from_za`.
2579        // HEAD: ZA=74184, AWR=182, NIS=0
2580        const ENDF: &str = include_str!("../../../tests/data/synthetic/nis_zero_rejected.endf");
2581
2582        let err = parse_endf_file2(ENDF).unwrap_err();
2583        match &err {
2584            EndfParseError::UnsupportedFormat(msg) => {
2585                assert!(
2586                    msg.contains("NIS=0"),
2587                    "expected NIS=0 in error message, got: {msg}"
2588                );
2589                assert!(
2590                    msg.contains("NIS >= 1"),
2591                    "expected 'NIS >= 1' guidance in error message, got: {msg}"
2592                );
2593            }
2594            other => panic!("expected UnsupportedFormat for NIS=0, got {other:?}"),
2595        }
2596    }
2597
2598    /// LRF=7 resonance LIST with N2/NX not divisible by L2/NRS is rejected.
2599    ///
2600    /// ENDF-6 §2.2.1.6 fixes NX = NRS · ceil(stride/6) where stride is NCH+1
2601    /// for KRM=2 and NCH+2 for KRM=3, so NX must be an integer multiple of
2602    /// NRS (the per-resonance packed-row count is constant within a spin
2603    /// group). A fixture with NRS=4 and NX=2 yields a fractional stride
2604    /// 6·NX/NRS = 3 floats per resonance, which would mis-align the
2605    /// resonance reads. Without the divisibility check, the existing
2606    /// `res_npl == 6*nx` guard passes (12 == 6·2) and the downstream
2607    /// `res_npl % nrs != 0` would also pass (12 % 4 == 0), producing the
2608    /// bogus stride. The new guard catches this directly.
2609    #[test]
2610    fn test_parse_lrf7_rejects_nx_not_multiple_of_nrs() {
2611        const ENDF: &str =
2612            include_str!("../../../tests/data/synthetic/lrf7_nx_not_multiple_of_nrs_rejected.endf");
2613
2614        let err = parse_endf_file2(ENDF).unwrap_err();
2615        match &err {
2616            EndfParseError::UnsupportedFormat(msg) => {
2617                assert!(
2618                    msg.contains("not a multiple"),
2619                    "expected 'not a multiple' in error message, got: {msg}"
2620                );
2621                assert!(
2622                    msg.contains("NX (2)") || msg.contains("(2)"),
2623                    "expected NX=2 to appear in error message, got: {msg}"
2624                );
2625                assert!(
2626                    msg.contains("NRS (4)") || msg.contains("(4)"),
2627                    "expected NRS=4 to appear in error message, got: {msg}"
2628                );
2629            }
2630            other => {
2631                panic!("expected UnsupportedFormat for NX not multiple of NRS, got {other:?}")
2632            }
2633        }
2634    }
2635
2636    /// LRF=7 spin group with zero resonances must be accepted when written in
2637    /// the canonical ENDF-6 §2.2.1.6 form NRS=0, NX=1, NPL=6 (a single
2638    /// six-float zero-filler row in the LIST body).
2639    ///
2640    /// OpenScale's reference writer at
2641    /// `external/openScale/repo/packages/ScaleUtils/EndfLib/endf/File2.cpp:683-697`
2642    /// pads the resonance LIST for empty spin groups:
2643    ///
2644    /// ```cpp
2645    /// list.setL2(spin->getNres());        // L2 = NRS = 0
2646    /// ...
2647    /// // nx must be at least 1, even if nres=0
2648    /// if (spin->getNres() == 0)
2649    ///     nx = 1;
2650    /// list.setN1(6 * nx);                  // N1 = 6
2651    /// list.setN2(nx);                      // N2 = 1
2652    /// ```
2653    ///
2654    /// A naive guard `if nrs == 0 && nx != 0 { reject }` would reject this
2655    /// canonical pattern (NX=1 ≠ 0). The relaxed guard
2656    /// `if nrs == 0 && nx != 1 { reject }` accepts it while still rejecting
2657    /// malformed shapes such as NRS=0/NX=2.
2658    #[test]
2659    fn test_parse_lrf7_nrs_zero_nx_one_canonical_empty_passes_guard() {
2660        const ENDF: &str =
2661            include_str!("../../../tests/data/synthetic/lrf7_nrs_zero_nx_one_canonical_empty.endf");
2662
2663        // The canonical empty spin group (NRS=0/NX=1) passes the relaxed
2664        // guard, so the skip consumes the range cleanly and the parse reaches
2665        // the no-evaluable-ranges rejection (the LRF=7 range is the file's
2666        // only range) rather than the NRS=0/NX guard error a stricter check
2667        // would raise.
2668        assert_rejected_no_evaluable_ranges(ENDF);
2669    }
2670
2671    /// LRF=7 spin group with NRS=0 but NX≠1 is rejected as malformed.
2672    ///
2673    /// OpenScale's writer (File2.cpp:683-697) explicitly pads NX to 1 when
2674    /// NRS=0, so any NRS=0 record with NX=0 (no filler row) or NX>1 (phantom
2675    /// filler rows with nothing to anchor them) is not a valid ENDF-6 emission.
2676    /// The previous over-permissive guard accepted NRS=0/NX=2 silently,
2677    /// leaving the parser to read two zero-filled rows as "no resonances"
2678    /// while the LIST body did contain data that some other reader might
2679    /// interpret as resonance parameters.
2680    #[test]
2681    fn test_parse_lrf7_rejects_nrs_zero_nx_two_malformed() {
2682        const ENDF: &str =
2683            include_str!("../../../tests/data/synthetic/lrf7_nrs_zero_nx_two_malformed.endf");
2684
2685        let err = parse_endf_file2(ENDF).unwrap_err();
2686        match &err {
2687            EndfParseError::UnsupportedFormat(msg) => {
2688                assert!(
2689                    msg.contains("NRS=0"),
2690                    "expected 'NRS=0' in error message, got: {msg}"
2691                );
2692                assert!(
2693                    msg.contains("NX=2"),
2694                    "expected 'NX=2' in error message, got: {msg}"
2695                );
2696            }
2697            other => panic!("expected UnsupportedFormat for NRS=0/NX!=1, got {other:?}"),
2698        }
2699    }
2700}