Skip to content

Joint multi-measurement fitting, optimizer resume, and a runnable example - #40

Merged
simonevadi merged 11 commits into
developfrom
feature/joint-multi-angle-and-resume
Aug 17, 2026
Merged

Joint multi-measurement fitting, optimizer resume, and a runnable example#40
simonevadi merged 11 commits into
developfrom
feature/joint-multi-angle-and-resume

Conversation

@simonevadi

Copy link
Copy Markdown
Contributor

Merges develop (the Nevière differential-method series) into this branch, generalizes joint fitting beyond grazing angle, and adds the first runnable joint-fit example.

Integrating develop

One textual conflict (CHANGELOG.md, both sides adding bullets — both kept). Everything else auto-merged, but that was misleading: develop renamed the BatchSimulationRunner settings and added solver=/solver_options= to the single-measurement runner construction, while this branch had added a second runner construction for the joint path that git left untouched with the old default_* kwargs. That would have been a TypeError on the first joint trial.

Generalizing the joint fit

Joint fitting was hardcoded to "same grating, several grazing angles". Each measurement now carries its own conditions — angle_mode, grazing_angle_deg, cff, diffraction_order, polarization — inheriting whichever it does not set from the run. Numerical settings (fourier_orders, solver, backend, max_workers) stay run-level: they describe how a curve is computed, not what was measured.

AngleMeasurementSpecMeasurementSpec and JointAngleMeasurementJointMeasurement. Neither name has shipped, so nothing to deprecate.

polarization was absent from grax_opt entirely, so every optimizer fit had been silently running the run_simulation default of s — the same gap develop just closed for the theta-search workflow.

solver/solver_options reach the joint path and are recorded in best_result.json. They join the resume fingerprint alongside each measurement's resolved conditions, so a resumed run cannot silently switch the physics it is fitting.

Two fixes found while reviewing the branch

Interrupted runs over-ran their trial budget on resume. The trial-record log is appended every trial but the Ax snapshot is only rewritten every checkpoint_interval trials, so an interruption left the log ahead of the snapshot. The recovered history then counted trials Ax was about to generate again — duplicating a row in trial_history.csv and running past total_trials. Reproduced before fixing: interrupting after 4 trials with checkpoint_interval=3 and a budget of 6 gave 7 completed trials and trial index 3 recorded twice. Records at or beyond the reconciled cursor are now discarded, since Ax is authoritative for what was issued. Only reachable with checkpoint_interval > 1, which is why the default of 1 hid it.

The single-measurement evaluation could read uninitialized memory. It allocated its efficiency array with np.empty and filled by result index, so a case the runner never returned left whatever was in memory as that point's efficiency. The joint path already guarded against this; both do now.

The example

examples/optimizer/optimizer_joint/ simulates a four-condition measurement set from a known grating, fits it, resumes to extend the budget, and reports how close each parameter came to the truth. The four conditions cover one generalization axis each: grazing angle, diffraction order, angle mode, polarization.

The measurements are always generated with rcwa, so fitting them with --solver neviere recovers the same geometry — width 0.6428 vs 0.6422, depth 14.6140 vs 14.6143.

It deliberately stops at 20 trials. The joint loss reaches the noise floor there; a 100-trial run was measured and improved the loss by 5.5% while making parameter recovery slightly worse, because past the floor the optimizer fits the noise realization rather than the grating. The example reports which parameters came back tightly and which did not, computed from the data rather than asserted.

Verification

  • Full suite: 475 passed, 5 skipped.
  • With ax and torch blocked (mimicking CI without the opt extra): 89 passed, 1 skipped — the new unit tests are CI-safe.
  • Docs build clean; the two orphan warnings are pre-existing files this branch does not touch.
  • Example runs end to end with both solvers.

🤖 Generated with Claude Code

simonevadi and others added 11 commits August 11, 2026 08:52
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Fits one parameter set against several measured curves recorded at
different grazing angles, each keeping its own energy grid. Simulated
efficiencies are reassembled by CaseExecutionResult.index because the
parallel batch runner yields in completion order.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Persists the Ax client snapshot alongside optimizer run state and an
append-only trial log, so an interrupted run can continue and total_trials
can be raised to extend a finished one. A problem fingerprint refuses to
resume into a changed search space.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The best-fit plot now reuses the winning trial's simulated curve, and
plots are rewritten only when the best improves. Penalized trials log
the failing case or exception instead of failing silently.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Covers out-of-order result reassembly, joint loss reductions, resuming
with an extended total_trials, and the fingerprint and corruption guards.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
AngleMeasurementSpec.from_mapping used a truthiness check that raised on
numpy arrays, which callers naturally pass for prepared energy grids and
efficiencies.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A non-resumed run opened trial_records.jsonl in append mode, so records
from an earlier run at the same output directory accumulated and a later
resume double-counted them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A non-resumed run rewrote created on every checkpoint write, so it
tracked the last write instead of when the run began.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Brings in the Nevière differential-method solver series (PR #39). The
CHANGELOG conflict was both sides adding bullets under Unreleased; both
sets are kept.

The merge auto-resolved cleanly everywhere else, but that was misleading:
develop renamed the BatchSimulationRunner settings and added solver=/
solver_options= to the single-angle runner construction in objective.py,
while this branch had added a second runner construction for the joint
path that git left untouched with the old default_* kwargs. That call now
matches the single-angle one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Joint fitting was hardcoded to "same grating, several grazing angles".
Each measurement now carries its own conditions -- angle_mode,
grazing_angle_deg, cff, diffraction_order, polarization -- inheriting
whichever it does not set from the run, so one fit can span a whole
measurement campaign. Numerical settings (fourier_orders, solver,
backend, max_workers) stay run-level: they describe how a curve is
computed, not what was measured.

AngleMeasurementSpec becomes MeasurementSpec and JointAngleMeasurement
becomes JointMeasurement, since neither is angle-specific any more.
Neither name has shipped, so there is nothing to deprecate.

polarization was absent from grax_opt entirely, so every optimizer fit
had been silently running the run_simulation default of s -- the same
gap develop just closed for the theta-search workflow. It is now a real
argument on both configs.

solver/solver_options reach the joint path and are recorded in
best_result.json, matching the single-measurement optimizer. They join
the resume fingerprint alongside each measurement's resolved conditions,
so a resumed run cannot silently switch the physics it is fitting.

Two fixes found while reviewing the branch:

An interrupted run over-ran its trial budget on resume. The trial-record
log is appended every trial but the Ax snapshot is only rewritten every
checkpoint_interval trials, so an interruption left the log ahead of the
snapshot; the recovered history then counted trials Ax was about to
generate again, duplicating a row in trial_history.csv and running past
total_trials. Records at or beyond the reconciled cursor are discarded
now, since Ax is authoritative for what was issued. Only reachable with
checkpoint_interval > 1, which is why the default of 1 hid it.

The single-measurement evaluation allocated its efficiency array with
np.empty and filled by result index, so a case the runner never returned
left uninitialized memory as that point's efficiency. The joint path
already guarded against this; both do now.

examples/optimizer/optimizer_joint/ is the first runnable joint-fit
workflow. It simulates a four-condition measurement set from a known
grating, fits it, resumes to extend the budget, and reports how close
each parameter came to the truth. The measurements are always generated
with rcwa, so fitting them with --solver neviere recovers the same
geometry to within a few parts in ten thousand.

The example deliberately stops at 20 trials. The joint loss reaches the
noise floor there; a 100-trial run was measured to improve it by 5.5%
while making parameter recovery slightly worse, because past the floor
the optimizer fits the noise realization rather than the grating.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@simonevadi
simonevadi merged commit 75eb0d8 into develop Aug 17, 2026
1 check passed
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