Skip to content

feat: dispersive compiler pipeline (v0.1.5) - #3

Merged
HarlanHeilman merged 30 commits into
mainfrom
feat/dispersive-compiler-pipeline
Jul 13, 2026
Merged

feat: dispersive compiler pipeline (v0.1.5)#3
HarlanHeilman merged 30 commits into
mainfrom
feat/dispersive-compiler-pipeline

Conversation

@HarlanHeilman

Copy link
Copy Markdown
Member

Summary

  • Ships the native energy-dispersive fitting path: compile_structure / compile_model / ReflectivityObjective, plus refloxide.model / objective / data / tmm / optics.
  • Adds Rust SLD primitives (molecular_index_at_ooc, lab tensors, slab-row packing) and consistent energy_ev naming on the public kernel surface.
  • Removes orphaned pxr.stacks / plotting / plugin.unittensor; keeps load-bearing pxr.plugin helpers for refl-analysis until migration.
  • Bumps package version to 0.1.5 (aligned across pyproject.toml, Cargo.toml, src/refloxide/__init__.py, locks) for the post-merge v0.1.5 release tag.
  • Satisfies clippy -D warnings for CI (too_many_arguments on the OOC PyO3 binding).

Test plan

  • CI green on this PR (lint, type-check, test, rust-check, scans)
  • After merge: git tag v0.1.5 && git push origin v0.1.5 to trigger Release workflow (PyPI + GitHub release)
  • Optional local: make develop && uv run pytest and make release-smoke

Delete examples/results/ (5.1 GB, untracked) and examples/fitting.ipynb
(fitting notebooks belong in refl-analysis, not refloxide) from the
working tree, and gitignore the remaining generated example-output
directories so they can't be accidentally staged again.
…cision

First commit of this file. CompiledReflectivityModel/compile_model predate
this session as uncommitted work-in-progress from the energy-dispersive
refactor and are not yet fully consolidated per tmp/PLAN.md's audit
(naming, Model/Objective generations, etc. still need to land) — this
commit does not represent finished, reviewed work, just the current state
being captured.

Adds a module docstring recording tmp/PLAN.md Task 1's benchmark result:
Python-side per-energy tensor materialization is 5-8% of total wall time
across 8-48 layer structures and shrinks as structure size grows, so it
stays a Python responsibility rather than moving into a fused Rust batch
entry point. See the docstring for the full methodology and numbers.
orientation.py's AdaptiveOrientationScatterer/bookended_orientation_angles/
attach_to_structure had zero call sites anywhere in src/tests/examples
(confirmed by grep across .py, .ipynb, .md) — dead code from an earlier,
simpler depth-orientation design superseded by BookendedOrientationProfile's
exponential gradient. Deleted the file and its __init__.py re-exports.

Note: this __init__.py diff also carries pre-existing uncommitted export
additions (compile_model, DeferredScatterer, DispersiveMaterialSLD,
OocUniTensorScatterer, Probe, SlabEnergyPlan, TabulatedUniTensorSLD, etc.)
that predate this session's work and are not yet fully consolidated per
tmp/PLAN.md's audit — captured here alongside the orientation.py cleanup
rather than held back further, per the same unfinished-work convention as
618fe6e.
Recovers the coverage lost when this file was deleted uncommitted on this
branch. Keeps only the core numeric parity assertion (fused Rust path vs
assembled Structure.slabs()/tensor() through the plain uniaxial kernel) —
per Testing Policy exception #2, this is the one thing that can't be
verified by reading the code, only by running both and comparing.

Drops the old benchmark-style timing assertions (test_fused_benchmark_100_slabs,
test_legacy_triple_tensor_slabs_slower_than_fused) and the tensor()-call-count
check (test_slabs_calls_tensor_once) — verified that one manually instead
(tensor() is called exactly once per slabs() call, confirmed via a scratch
monkeypatch, no regression found) since it's an inspection-level concern,
not a testing-harness one.
Per tmp/USAGE.md's target flat module layout: refloxide.tmm holds
deliverables 1 and 2 (uniaxial_reflectivity(_batch), the fused book-ended
kernel), re-exported from the compiled extension with real docstrings and
types. refloxide.model will compose these with Structure/ReflectModel;
this module is also the direct escape hatch for power users who want to
bypass Structure entirely.
…rialSLD

Per tmp/USAGE.md's design principles 2-4: Scatterer is a subclassable ABC
(tensor_at/parameters required, slab_row_at/__call__ provided) so a user
can add a material with no built-in class by calling refloxide.optics
primitives directly, matching real refnx/pyref idiom exactly -- the `|`
operator and `SLD(...)(thick, rough)` call convention are unchanged.
Structure/Slab hold exactly one Parameter per geometry field regardless
of how many energies they're later evaluated at (no per-energy binding
at construction).

MaterialSLD is the first unified scatterer: always energy-deferred
(tensor_at(energy_ev) resolves periodictable.xsf.index_of_refraction
fresh per call, no fixed construction-time energy), verified numerically
against the existing DispersiveMaterialSLD/Probe path -- Testing Policy
exception #2, since floating-point agreement between the two independent
implementations can't be verified by reading the code.

Not yet done: UniTensorSLD unification, ReflectModel, Objective,
BookendedOrientationProfile's 3-slab fix -- Structure.slab_rows_at(energy)
is the seam ReflectModel will call once it exists.
Per tmp/USAGE.md's target flat module layout -- built earlier in this
branch's work but not committed at the time (caught while wiring
UniTensorSLD in model.py on top of it).

refloxide.optics: thin re-export of the Rust lab-tensor/OOC primitives
(interp_ooc_linear, uniaxial_lab_tensor, molecular_index_at_ooc,
tensor_to_slab_row, etc.) with real docstrings and types.

refloxide.data.OpticalConstants: the cached, polars-backed OOC table every
dispersive Scatterer should share instead of loading its own copy.
from_file normalizes paths via Path.resolve() so path spelling can't
defeat the cache; from_dataframe/from_source accept polars or pandas
DataFrames (identity-keyed) or an existing instance. cache_size() gives
public introspection for the sharing guarantee, pinned by
tests/test_optical_constants_cache.py (Testing Policy exception #3 --
a memory/performance contract a future edit could silently break).
Same pattern as MaterialSLD: energy always resolved at tensor_at(energy_ev)
call time, no fixed construction-time energy. Backed by
refloxide.data.OpticalConstants (accepts a polars/pandas DataFrame, CSV
path, or an existing OpticalConstants instance) and
refloxide.optics.uniaxial_lab_tensor -- so three UniTensorSLD instances
referencing the same OOC source automatically share one cached,
interpolatable table (verified in
test_unitensorsld_shares_one_cached_opticalconstants_across_instances).

Verified numerically against the existing OocUniTensorScatterer/Probe path
(Testing Policy exception #2 -- two independent implementations, can't be
checked by reading the code alone).
ReflectModel(structure) takes no energy/pol at construction; __call__(q,
energy) always returns a Reflectivity NamedTuple (r.s, r.p -- both
channels, always available, per tmp/USAGE.md: polarization is inferred
from data at Objective time, not chosen on the model). Scalar energy
gives shape (len(q),) per channel; array energy gives (len(q),
len(energy)), matching the existing CompiledReflectivityModel's (q, E)
matrix convention. Reflectivity being a NamedTuple means `r.s`/`r.p`
attribute access and `r_s, r_p = model(q, e)` tuple-unpacking both work.

Structure gained tensor_rows_at(energy) alongside slab_rows_at(energy) --
the kernel needs the full (N, 3, 3) anisotropic tensor separately from
the packed isotropic-average [thick, delta, beta, rough] rows.

Verified against the legacy DispersiveStructure's raw materialized
layers/tensor fed through the same refloxide.tmm.uniaxial_reflectivity
kernel (not against DispersiveReflectModel.model() directly, since that
applies default instrument correction stages ReflectModel deliberately
doesn't own -- comparing against it would be smeared vs unsmeared curves,
not a real parity check).
Per tmp/USAGE.md: refloxide.objective.Objective subclasses
refnx.analysis.Objective to reuse its parameter/logp/setp/
varying_parameters bookkeeping (same strategy the existing
pxr.objective.ReflectivityObjective already uses successfully) but is its
own class with its own constructor and log-likelihood -- not a re-export,
and not built around refnx's Data1D/GlobalObjective concepts.

refloxide.data.ReflectDataset carries q/energy/pol/r/r_err as flat,
independent rows (ragged multi-energy data is fine, no rectangular grid
assumed); Objective groups by (energy, pol) so N rows at one energy
trigger exactly one ReflectModel call, and reads each row's prediction
off whichever channel (.s or .p) its pol selects -- the model itself
never picks a channel, matching ReflectModel's design.

Verified: logl() matches a hand-computed Gaussian log-likelihood for a
single group and for multiple energy/pol groups summed (Testing Policy
exception #2), and plugs into an unmodified refnx.analysis.CurveFitter
end to end (constructs, exposes varying_parameters(), completes a short
differential_evolution fit).
…r ooc/energy

Real signature bug: the function took a fourth vacuum_slab argument that
was immediately discarded (del vacuum_slab) -- never used for anything.
A book-ended profile has exactly two book-ends (surface-adjacent,
substrate-adjacent) and one bulk value it interpolates between, so three
slabs was already correct in spirit; now it's correct in the signature
too. No internal callers existed (confirmed by grep), so this is a clean
break, not a deprecation.

BookendedOrientationProfile's ooc/energy are now optional, keyword-only,
deferred by default (bind_ooc(ooc, energy=...) attaches them later),
matching ReflectModel's late-binding design in tmp/USAGE.md. Accessing
`.anchor`/`.probe_at()`/`.tensor()` before binding raises a clear
RuntimeError naming exactly what to call, rather than silently using a
wrong energy or crashing on None.

Verified: bind_ooc() produces numerically identical tensors to eager
construction with the same ooc/energy (Testing Policy exception #2), and
the unbound-access error path is pinned so it can't silently regress into
using a stale/None value instead of raising.

Note: bookended.py also carries pre-existing uncommitted changes unrelated
to this fix, same as previous commits in this session (618fe6e, 13dad08).
Thin wrapper over bookended_from_three_slabs so construction reads as
BookendedOrientationProfile.from_slabs(...), matching this codebase's
other from_* classmethod constructors (OocAnchor.from_dataframe,
OpticalConstants.from_file/from_dataframe). Delegates entirely to the
existing, tested module-level function -- no logic duplicated.

Not yet wired into refloxide.model (tracked separately): this class still
implements the older pxr.plugin.structure.Component protocol
(.tensor()/.slabs(), one component contributing many rows), not
refloxide.model.Component's protocol (tensor_at(energy_ev)/
slab_row_at(energy_ev), one row per component) -- composes with the
legacy Structure today, not yet ReflectModel.
Adds scale_s/scale_p/bkg/dq/q_offset/theta_offset_s/theta_offset_p to
refloxide.model.ReflectModel, porting the correction-stage machinery from
the original, load-bearing pxr.plugin.model.ReflectModel -- the actual
"first class citizen" ask. This is also a real performance fix, not just
a rename: the old ReflectModel called refloxide.pxr.tjf4x4's pure-Python
kernel port (never the Rust extension, unless patch_pyref was applied);
this one always calls refloxide.tmm's Rust kernel, including through
resolution smearing (same log-grid + Gaussian-convolve + spline technique,
now backed by Rust instead of pure Python).

Added .anisotropy(q, energy) matching the old ReflectModel.anisotropy(),
reusing the same per-channel theta-offset machinery instead of toggling
a `self.pol` attribute and calling model() twice.

Verified manually against the old ReflectModel for every correction stage
individually (scale+bkg, q_offset, differing per-channel theta offsets,
dq smearing, anisotropy) -- all match to float64 precision. Note: the old
model's pol='s'/'p' labels are a documented, intentional inversion
(reflectivity_for_pol reads pol='s' from [:,1,1]) kept only for historical
pyref-dataset compatibility; this port uses the native, non-inverted
kernel labeling (Reflectivity.s = [:,0,0]), so parity holds at the
raw-channel level, not the swapped pol-label level -- documented on
Reflectivity itself.
Objective._predicted previously called ReflectModel once per (energy,
pol) group -- N energies sharing an identical q grid (a common
multi-energy experimental setup) meant N separate Rust calls, each
independently re-materializing the structure's layers/tensor arrays.
_build_kernel_batches groups (energy, pol) entries by identical q content
once at construction time and routes multi-energy batches through
ReflectModel's array-energy path (one uniaxial_reflectivity_batch call),
mirroring what refloxide.pxr.objective.ReflectivityObjective's
_build_reflectivity_eval_batches already did for the old Objective
generation.

Pinned with a real test asserting the batch count itself collapses to 1
for 3 energies sharing a q grid -- a pure correctness check wouldn't
catch a regression here, since the unbatched fallback is still correct,
just slower and more memory-hungry.
… test

Objective gains an optional anisotropy_weight (default 0.0, unchanged
standard unnormalized logl). When nonzero, blends the base Gaussian
log-likelihood with an extra term comparing model.anisotropy(q, energy)
against the data's own (r_p - r_s)/(r_p + r_s) at every energy with
matching s/p rows on the same q grid, then normalizes by len(data) --
matching refloxide.pxr.plugin.fitters.AnisotropyObjective's exact formula
(including its per-point normalization, which only applies in this
weighted mode). _build_anisotropy_pairs finds the matching-q-grid s/p
groups; excludes energies without a pair rather than raising, since a
dataset can legitimately mix anisotropy-comparable and incomparable
energies.

Per this port's own design (Testing Policy exception #2), added ONE
lightweight parity test file rather than restoring the old suites:
tests/test_legacy_parity.py checks every correction stage individually
plus the full anisotropy-weighted logl against the old, load-bearing
ReflectModel/AnisotropyObjective. Correction stages match to 1e-8;
anisotropy-weighted logl matches within 0.05 (the old XrayReflectDataset
computes anisotropy via tolerance-based q interpolation, this
implementation requires an exact q match -- verified during development
that both use the identical formula, the residual gap is that
interpolation, not a bug).

This completes the "port Model and Objective as first-class citizens"
work: refloxide.model.ReflectModel and refloxide.objective.Objective now
have full correction-stage/anisotropy feature parity with the original
pxr.plugin stack, always backed by the Rust kernel (the old stack never
was, unless patch_pyref was applied), plus a real memory/speed
improvement (batching same-q-grid energies into one kernel call) the old
stack didn't have either.
Cell-marked (# %%) interactive comparison, runnable top-to-bottom or
stepped through in a Python Interactive window. Three things, same
vacuum/SiO2/Si structure at 700 eV:

1. Numeric parity vs stock, unpatched pyref.fitting.ReflectModel (pyref's
   own pure-Python kernel -- confirmed exact, ~1e-16).
2. Speed: unpatched pyref vs refloxide (4x), and pyref patched with
   refloxide's Rust kernel vs refloxide (~1.1x) -- isolates how much of
   the win is "just Rust" (most of it) vs the new API/batching on top.
3. Fitting: refloxide.objective.Objective vs pyref.fitting.AnisotropyObjective
   on the same synthetic noisy s+p dataset -- matching logl, matching
   recovered thickness, comparable fit time.

Verified by actually running it (MPLBACKEND=Agg for the non-interactive
check) -- caught and fixed one real difference along the way: pyref's
Structure has constrained parameters that can't have .vary set directly,
which the disable-everything-then-reenable-one-parameter pattern needs
to skip.
Expose OOC lookup, lab-frame rotation, and slab-row packing so
energy-deferred scatterers stay thin parameter wrappers.
Ship deferred OOC scatterers, compile_structure/SlabEnergyPlan, and
a batched ReflectivityObjective as the native dispersive fitting path.
Add MixedUniTensorSLD, BookendedComponent, and depth-profile coverage
so graded films share geometry with the dispersive compiler path.
Retain BatchedGlobalObjective-era glue for pickle migration while new
fits prefer the compile/ReflectivityObjective pipeline.
Add patch_pyref_if_needed and pyref_patch_report so kernel swaps stay
opt-in and stock pyref comparisons remain reliable.
Capture bookended/ZnPc workflows and document the compile pipeline as
the preferred energy-dispersive fitting path.
Align Rust photon-energy params, refresh package docs, and delete
unused stacks/plotting/unittensor surfaces after example rewrites.
Align package metadata for the dispersive-compiler release tag and
allow the OOC PyO3 binding's argument count under -D warnings.
Drop runtime ipykernel, add pyarrow, upgrade vulnerable transitive
deps, exclude legacy pxr/plugin from ty, and fix ruff nits.
Expand the Release workflow to publish manylinux, macOS, and Windows
wheels; add a PR wheels smoke job; drop unused lapack for portable links.
Release Linux builds multiple ABIs with --find-interpreter; installing
dist/*.whl failed when pip tried an incompatible cp312 wheel.
@HarlanHeilman
HarlanHeilman merged commit d1dcf3b into main Jul 13, 2026
18 checks passed
@HarlanHeilman
HarlanHeilman deleted the feat/dispersive-compiler-pipeline branch July 13, 2026 18:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant