Skip to content

feat(inelastic): refine band-bottom tables in lindhard run (#339) - #355

Merged
rjwalters merged 1 commit into
mainfrom
feature/issue-339
Oct 10, 2026
Merged

rjwalters merged 1 commit into
mainfrom
feature/issue-339

Conversation

@rjwalters

@rjwalters rjwalters commented Oct 10, 2026 •

Copy link
Copy Markdown
Member

Closes #339

Follow-up to #351, which added the library option (Part of #339). The operator approved replacing the midpoint-only test with a stricter test at several points per cell that uses no tuned constants (#339 comments).

Criterion

A cell overlapping E - E_F = 2..20 eV is bisected at its midpoint when the linear interpolation of the exact rate misses it by more than 1 % at any test point:

  1. The quartiles and the midpoint (rate_test_points). Each quartile is formed as the midpoint of a half, so it is bitwise the point the next bisection would test. The set is fixed and dyadic, and no count was tuned.
  2. The estimated error peak, where step 1 passes (rate_error_peak). This is the vertex of the parabola through the largest sampled signed error and its two neighbours, with the error at the cell ends exactly zero. It is parabolic interpolation of an extremum (the step in Brent's method) and has no parameters. The neighbours never exceed the centre sample in magnitude, so the vertex lies within half a spacing of it.

Why step 2 is needed: with quartiles alone (measured first), the Al single pole at the se_yield grid still missed at 1.0008 % (6.1 eV) and Al Mermin from 10 eV at 10 per decade at 1.031 %. In the first case the peak lies between the first quartile (0.84 %) and the midpoint (0.99 %) of the cell 5.3..7.2 eV. A denser fixed set would need sixteenths to catch it, which would be fitting the sample count to this case. The vertex finds the peak directly.

When a cell still to be tested has a midpoint that rounds to one of its ends, the build still errors (the message now says "failed at a test point"). The max_rows error now names the failing test point.

Acceptance (inelastic_low_energy rate mode, dense 0.1 eV gate over 2..20 eV, 1 %)

Largest error (E - E_F where it falls); rows = coarse bracketing rows + rows added:

Table se_yield grid (5 eV, 10 ppd) rows default (10 eV, 20 ppd) rows
Al single-pole 0.967 % (16.4) pass 6 + 23 0.843 % (3.1) pass 11 + 24
Al Mermin 0.656 % (18.6) pass 6 + 10 0.880 % (4.8) pass 11 + 7
Si single-pole 0.843 % (15.2) pass 6 + 25 0.953 % (4.9) pass 10 + 22
Si Mermin 0.934 % (3.0) pass 6 + 7 0.721 % (15.8) pass 10 + 5

The three non-acceptance grids that failed before:

Table before (#351) now rows
Al SP 5 eV / 20 ppd 1.43 % (17.6) 0.772 % (15.9) pass 11 + 23
Al Mermin 10 eV / 10 ppd 1.03 % (4.6) 0.893 % (2.7) pass 7 + 11
Si Mermin 10 eV / 10 ppd 1.40 % (18.6) 0.842 % (18.2) pass 6 + 10

All 32 refined tables of the mode pass (5 and 10 eV × 10/20/40/80 ppd × Al/Si × SP/Mermin). Inputs are the se_yield.py Al and Si default inputs at 400 eV. Runs were done one at a time with RAYON_NUM_THREADS=2 (Al about 8 min, Si about 1.3 min). No δ(E) runs.

CLI switch (all cells pass, so it landed)

Notes for review

  • Cost: each refinement level evaluates 3 to 4 scalar rates per tested cell, and a refined table has 5 to 25 extra rows. With penn-full and a band this adds build time; full Penn was not run (memory constraint).
  • Known limitation: an optical ELF that starts more than 2 eV above E_F under a Penn model has a rate of exactly zero until elf_min, inside the window. The refinement would then bisect toward that onset and fail with the "exhausted to rounding" error. No shipped ELF does this: the lowest energy is at most 0.5 eV (Si Yang 2019 and the synthetic example). I can file this as a follow-up if wanted.

Verification

  • cargo fmt --all --check: ok
  • cargo clippy --workspace --all-targets -- -D warnings: ok
  • cargo test -p lindhard --lib: 326 passed (new: rate_test_points_are_the_quartiles_and_midpoint, rate_error_peak_is_the_vertex_of_the_parabola_at_the_largest_sample; the window test now checks every test point)
  • cargo test -p lindhard --test penn_inelastic_table --test mermin_inelastic --test electron_secondaries --test golden --test electron_bench_determinism: 18 / 12 / 34 / 3 / 1 passed
  • cargo test -p lindhard-cli --lib: 28 passed (including table_cache and the thread-count check in rate_refinement_is_the_same_on_any_thread_count)
  • cargo test -p lindhard-cli --test examples: 38 passed
  • python3 validation/update_docs.py --check: matches

🤖 Generated with Claude Code

loom dashboard

The rate refinement now tests each cell at its quartiles and midpoint
and, where those pass, at the vertex of the parabola through the largest
sampled error and its two neighbours (parabolic interpolation of the
peak). The midpoint alone (#351) missed in four tables because the
error peaks off the midpoint; the quartiles alone still left the Al
single pole at the se_yield.py grid at 1.0008 %. No constant is tuned:
tolerance and window are #291's, the quartiles are the next bisection
level, and the vertex is parameter-free. A cell exhausted to rounding is
still an error.

With it every refined table of the `rate` mode passes the dense 1 %
gate (Al and Si, single pole and Mermin, 5 and 10 eV, 10 to 80 per
decade), so `lindhard run` builds the inelastic table of every material
with a band with RateRefinement::default(). The elastic table and the
tables of bandless materials are unchanged. The table-cache key gains
inelastic.rate_refinement (KEY_VERSION 4), a cached inelastic table must
contain the run's grid in order, and electron_tables.csv matches elastic
rows by energy. Committed δ(E) tables are stale pending #287.

Closes #339
Loom-Issue: #339

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
@rjwalters rjwalters added loom:review-requested PR ready for Judge to review. Applied by: Builder when opening PR. loom:reviewing Judge is reviewing this PR. Applied by: Judge only. Stale after LOOM_STALE_REVIEWING_MINUTES (30m). labels Oct 10, 2026
@loom-fleet-dispatch

loom-fleet-dispatch Bot commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Judge pass: still carries a fresh loom:reviewing claim (claimed 2026-10-10T18:03:16Z, idle 14m) — standing down without reclaiming. Not stomping.

Stand-down passes against this claim: 3 of 3 (streak cap met); claim age 14m of 30m (age floor NOT met). The bounded fallback force-reclaims only once BOTH are met (#9927). This comment is edited in place on each pass rather than reposted (#5123, #6514).

@rjwalters

Copy link
Copy Markdown
Member Author

Judge verdict: approved (head 648744d)

Reviewed against #339 (including the operator decision), CLAUDE.md and CONTRIBUTING.md. CI: all checks pass. Merge state: CLEAN.

1. Parabolic-vertex step and the operator's approval

This is within the approval. The operator approved "several interior points per cell" that is stricter, non-empirical and has no tuned constants. Quartiles plus midpoint is a fixed dyadic set: each quartile is bitwise the midpoint the next bisection would test, and a unit test checks this. The vertex is the standard parabolic-interpolation step (Brent 1973, cited) and has no parameter. It only adds a test point, so it can split more cells but never fewer, which makes the criterion stricter. The stated reason holds. Quartiles alone left the Al SP 5 eV/10 ppd peak between Q1 (0.84 %) and the midpoint (0.99 %). Choosing a denser fixed set to catch that one cell would be choosing the count from the result.

Correctness: the centre sample c is the largest of the three in magnitude, so it is a signed extremum of the triple (c >= l, r when c > 0). That gives |l - r| <= |l - 2c + r|, so the vertex offset is at most h/2 and the claim in the doc comment is right. Because of that bound the a < v < b check never actually binds, but it is a harmless guard. The degenerate cases are handled: c = 0, zero curvature (a flat triple) and non-finite values all return None. The tie-break prefers the midpoint, then Q1 over Q3, which is deterministic. The vertex is only computed when the quartiles pass, so an infinite miss never reaches it. Peaks are evaluated with par_iter and collected in cell order, which keeps the result deterministic.

2. Exhaustion and max_rows

Still correct. The rounding check still runs on cells still to be tested (children of a failed cell) at their midpoint. A quartile that rounds to an end gives a zero miss and cannot produce a false split. The new wording, "failed at a test point", is accurate. The max_rows message now names the failing point and the interpolated and exact rates at that point.

3. CLI, cache and CSV

  • rate_refinement(r, m) returns Some only on EnergyAxis::BandBottom. Elastic tables and bandless inelastic tables get None, so they are built from identical options and are byte-identical. The bandless Cu test still asserts the grid equals the input grid.
  • Determinism: the library test compares 1, 3 and 8 threads. The CLI reuse test covers the band example built with 4 threads and cache hits on 1 and 8 threads, and the electron determinism test compares 1 and 4 threads. (Nit: the PR body lists rate_refinement_is_the_same_on_any_thread_count under lindhard-cli --lib. It is in lindhard.)
  • Cache: the new inelastic.rate_refinement field (the Debug of the struct, from the same function the build uses) and KEY_VERSION 4 are both covered by the guard test. The key already includes git describe, so a future change to the criterion invalidates the key even though Debug does not encode the algorithm. contains_in_order is a correct bit-exact subsequence check, and its tests cover order, a missing energy and a one-ulp miss. It cannot accept a wrong table: the key and SHA-256 are what identify the table, and the grid check is defence in depth. The elastic check is still exact.
  • tables_csv: the elastic grid is sorted, so binary_search_by(total_cmp) on exact energies is correct, and the elastic column is empty at added rows as documented.

4. Acceptance numbers

Spot-checked: I ran inelastic_low_energy <Si default @ 400 eV> rate locally (release build, RAYON_NUM_THREADS=2, 64 s). Every Si number in the PR reproduces exactly:

  • single pole: 5/10 at 0.843 % (15.2), 6 + 25; 10/20 at 0.953 % (4.9), 10 + 22
  • Mermin: 5/10 at 0.934 % (3.0), 6 + 7; 10/20 at 0.721 % (15.8), 10 + 5; 10/10 (failed before) at 0.842 % (18.2), 6 + 10
  • all 16 refined Si tables pass

The tolerance (1 %) and window (2 to 20 eV) are #291's, and nothing was widened. Al was not rerun because it takes about 8 minutes.

5. penn-full build time and the ELF-onset limitation

Both are acceptable as disclosed. The build cost is 3 to 4 scalar rates per tested cell per level plus 5 to 25 extra rows. It is paid once per cache key, and full Penn is not in the δ configurations. The ELF onset above 2 eV fails loudly, not silently, and no shipped ELF has it. I filed #357 (loom:triage) covering both: a fix or a clearer refusal for the onset, plus one penn-full timing.

6. CHANGELOG and docs

Accurate. "Changed" says that results of runs with a band change, that elastic and bandless tables are unchanged bit for bit, that the δ(E) tables are stale until #287, and describes the CSV and cache-key changes. No stale "does not use it yet" text remains. docs/validation.md says this settles #291 for the δ grid.

Non-blocking nits (optional, no rework required)

  • RateRefinement::tolerance doc says "(its quartiles and midpoint)" but leaves out the estimated peak.
  • rate_test_points doc says the quartiles "bracket the peak". That is not guaranteed, and rate_error_peak only searches near the largest sample.
  • docs/cli.md: "a few tens: Al and Si, 5 to 25 rows" reads inconsistently.
  • The se_yield.py comment on the 10 ppd grid ("stays at 10 until the grid gains rows from E_F upward (Resolve the inelastic rate from E_F upward on the band-bottom axis: extra table rows above the Fermi level #339)") could now say the grid is settled.
  • The bandless inelastic cache lookup is now also a subsequence check. It could stay exact when rate_refinement is None, but this is defence in depth only.
  • Optional efficiency: a split cell's quartiles are its children's midpoints, but their exact rates are evaluated again at the next level.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

loom:pr PR approved by Judge, ready for Champion auto-merge. Applied by: Judge.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Resolve the inelastic rate from E_F upward on the band-bottom axis: extra table rows above the Fermi level

1 participant