SEP 41 — Recipes, and the hooks a front end needs#
SEP |
41 |
Title |
Recipes, and the hooks a front end needs |
Author |
John C. Travers |
Drafting agent |
Claude Opus 5.5 (2026-10-31) |
Status |
Final |
Review status |
Reviewed |
Reviewers |
John Travers (human); Opus 5.5 (AI); Codex (AI) |
Type |
Change |
Created |
2026-10-31 |
Resolution |
Accepted 2026-11-01 by John Travers, after the maintainer’s human review, three AI reviews and the responses to them, and decisions Q1–Q12 (Part III) (LupoLab/solprop#112). |
Requires |
SEP 42 (resuming by what matters to the run; accepted) |
Supersedes |
— |
Superseded-by |
— |
Implementation |
Complete 2026-11-02: John Travers, with Opus 5.5, on |
Manual pages |
Recipes; Pulses; Design tools; Scans; Progress; Resuming; Output; Plotting; Free-space beams |
Part I — Proposal#
1. Abstract#
solprop is driven only from Python today. A complete simulation is a call to one of six
prop_* functions, and nothing in solprop can write that call down, read it back, or
explain its parameters to anything other than a person reading the docstrings. This SEP adds
a recipe: a versioned TOML description of a simulation that solprop reads, validates,
builds, runs, exports as a plain Python script, records in its output and resumes from.
Around it come the smaller facilities that any front end needs from solprop instead of
copying from it:
parameter metadata, structured validation and a capability query;
a supported observer of saved planes, scan progress, a cost estimate and a portable stop;
launch into chosen modes, beam displacement and tilt;
design solvers for every guided route, and pinned parameters that use them;
previews of the modes and the input, and a statement of what a results file can give;
measured-pulse loaders and geometry drawings.
The first user is a desktop front end being designed separately; scripts and batch systems
gain the same things (solprop run recipe.toml). Success is judged by round trips, with the
noise seed fixed: a recipe and the Python call it stands for build the same Simulation
(equal configuration_hash); a recipe exported to Python and run gives the same run,
execution settings included; and a file written from a recipe resumes from the file alone.
The Python interface changes are additive: every name is new and every new keyword defaults to today’s behaviour. No output-schema bump is needed: the recipe is a top-level group written once, and the launch and beam changes (S5, S6) add recorded fields, which SEP 42 makes additive. This SEP therefore lands after SEP 42.
2. Motivation and scope#
A front end must:
show every parameter with its unit, default, range and documentation;
check a setup as it is typed and put each error next to its field;
offer only the options a route and species support;
watch a run as it goes, and stop it;
save a setup and reopen it, and reopen a results file with the setup that made it;
say whether a stopped run can be resumed, and resume it;
say what each results file can show.
Today each of these would mean a hand-written copy of rules that live in solprop’s code, and the copy would drift with every release. The principle of this SEP is that solprop holds the physics, the defaults and the rules; a front end holds only presentation.
The same gaps hurt script users. A results file does not record the call that made it, so a run can be resumed only by the script that started it (the resume key checks a rebuilt simulation; it cannot rebuild one). A batch job is a Python file per configuration. A scan’s builder must be a module-level function for pool executors (scans), which a declarative description gives for free.
In scope (the labels S1–S18 are used throughout):
Change |
Section |
|
|---|---|---|
S1 |
|
5.1 |
S2 |
The recipe recorded in the output; resume from a file alone |
5.2 |
S3 |
A supported observer of each saved plane |
5.3 |
S4 |
Progress for scans |
5.4 |
S5 |
Launch into chosen modes on modal routes |
5.5 |
S6 |
Displacement and tilt on free-space beams |
5.6 |
S7 |
Design solvers for every guided route; radius from a zero-dispersion wavelength |
5.7 |
S8 |
A capability query for routes and species |
5.8 |
S9 |
Machine-readable parameter metadata |
5.9 |
S10 |
Structured validation errors and warnings, a check and grid suggestions |
5.10 |
S11 |
A cost estimate before a run |
5.11 |
S12 |
Measured-pulse loaders |
5.12 |
S13 |
Geometry drawings in the library |
5.13 |
S14 |
Route keywords: fill the gaps, document the deliberate differences |
5.14 |
S15 |
The writer-lock sidecar, documented in the output guide |
5.15 |
S16 |
Result capabilities: what a results file can give |
5.16 |
S17 |
A portable stop, and a resumability query |
5.17 |
S18 |
Previews: the modes and the input a recipe would build |
5.18 |
Out of scope:
the front end itself, which is a separate project;
remote execution and a network service (a recipe and
solprop runare how remote work is done for now, through the existing executors);arbitrary Python inside a recipe (custom pulse shapes or responses, callables): the way out is the Python export;
a graphical recipe editor in solprop;
extending a finished or stopped run onto a changed grid (Q4);
any change to the physics, the kernel contract or the steppers.
3. Context#
Entry points.
prop_gnlse,prop_capillaryandprop_waveguidetake keywords.prop_radial,prop_freeandprop_planartake aTimeGridfirst, then objects:space,responses,initialand the keywordsettings, all three with an optionalframe;prop_radialandprop_freealso take amedium. Each has aSimulation.from_*constructor, so a simulation can be built without running it, andSimulation.run(output, progress=..., resume=..., memory_budget=..., tdse_archive=..., snapshots=...)runs it. The keyword surfaces differ: theprop_*functions also takeoutput,progress,progress_period,resume,snapshotsand, on the capillary,tdse_archive, whichfrom_*does not;prop_radial,prop_freeandprop_planaralso takeabsorberandabsorber_scheme, which theirfrom_*hold insettings;boundary_checkis on everyfrom_*;from_waveguidetakesmemory_budget, which admits a guide’s tables at build; andfields,field_planesandresume_historyare on no keyword surface, only onSimulation.settings.Names. The route keywords name the reference wavelength
wavelengthand the pulse shapeshape, as the pulse objects andsolprop.toolsdo (since the 0.1.0 release preparation, PR #64).Progress.
ProgressReporteris aruntime_checkableprotocol with four calls (started,step,plane,finished), extended by optional protocols (InterruptibleReporter.interrupted,SummarisingReporter.summary) that a reporter may implement;NullReporterimplements both. Membership is checked by name.plane(z, summary)receives the plane’s cost, not its field. A reporter’s exception propagates through the run loop, which records the run as failed.Output and resume. The HDF5 output (ADR-058) records the resolved configuration, metadata and
configuration_hash. A file of any other output schema is refused wherever it is opened. SEP 42, on which this SEP rests, makes the resume gate a comparison of the stored and requested records, addsresume="force", and makes a new recorded field or a top-level group additive. When a call gives no noise seed, the rebuild takes the seed recorded in the file. The call’s arguments are not stored, and the stored configuration cannot rebuild a simulation.RunOutcome.statusisrunning,complete,budget_exhaustedorfailed. SIGTERM and Ctrl-C raiseRunTerminated, aSystemExit, at once, and are held only while a commit is being written; the state is written, andresume=Truecontinues. A dense run whose last plane is interpolated resumes only withresume="interpolated".Scans.
run_scan(scan, build, executor=...)already reports progress:"auto"gives an outer bar or one log line per finished point; the inner runs are quiet. A scan’s builder must passpoint.seed.python -m solprop.scanis the command-line entry point.Design tools.
solprop.toolshaspressure_zdw,pressure_rdw,capillary_design,soliton_paramsand others, built on the Marcatili capillary;pressure_rdwalso takes alatticeand builds aVincettiMode.pressure_zdwbrackets once over pressure, inside a zero-dispersion search that samples \(\beta_2\) over wavelength and discards sign changes through a pole;pressure_rdwsamples 129 logarithmic and 129 linear pressures. A mode need not know its fill’s Kerr response. Modes exposebeta,beta2,zdw,aeffand loss.Modes.
prop_waveguidealready selects a launch mode withchannel, by index or display label (labels display asHE1,1:even), or computes a launch from anincidentbeam and acoupling. General modal bases measure physical energy with a flux metric that has cross terms (general modal).Errors. The builders raise
SolpropErrorsubclasses (DriverError,TranslationError,ModeError);DriverErroris also aRuntimeError, not aValueError. Most carry a message only;ResponseBudgetErrorcarriesz,fieldandcalls. Warnings (MaterialRangeWarning,BoundaryWarning) carry a message.Memory.
solprop.driver.memoryalready estimates a run’s persistent memory and admits it against a budget (MemoryBudgetError).Pulses and beams.
DataPulsetakes arrays and keeps a supplied unwrapped phase; there are no file loaders.SpatialPulse(temporal, waist, order, propz, jones)has no displacement or tilt;solprop.coupling.Alignment(displacement, tilt, model_error)applies them as a coupling step, with the tilt a phase screen in the medium’s real wave number.Results. The processing functions accept spatial files but do not return physical powers from them; compression refuses spatial and general modal outputs; general modal reconstruction needs the guide and reference tables, of which a file may hold only the identities.
Figures. The geometry drawings in the user guide are generated by a docs script and styled with CSS custom properties, which only a browser renders.
The writer lock. The
*.writer.locksidecar is documented under snapshots in the output guide.
Open issues. From a keyword search of this SEP’s subjects against the open issues, reading those that matched. It fixes none in full.
To resolve first:
It changes, or meets the same code as:
#98: underscore fields in the signature of
SimulationSettings, which S9’s sync test checks against.#125:
Recipe.scan()builds each point afresh and passes its seed, which avoids this.#131: reading a scan file’s recipe (S2) goes through
ScanOutput’s schema check.#83 and #95: the boundary warning, which S10 gives a
parameter.#109: a refactor of
driver/simulation.py.
4. Theory#
No new physics. Four definitions matter:
Recipe equivalence. A recipe \(R\) and a Python call \(C\) are equivalent when
Recipe(R).build()and theSimulationbuilt by \(C\) have equalconfiguration_hash, with the noise seed fixed in both. The hash already covers everything that decides the computed fields (grid, modes, configuration, noise, stepper, materials and launch), so this is the right identity and needs no second definition. Equivalence runs one way, from table to object: there is no step from an object back to a table (§5.1).Quantities. Recipe values are numbers in the keyword’s documented SI unit, or quantity strings (
"125 um","1.2 bar","7.5 fs","2e14 W/cm^2") converted to that unit. The unit is the one the docstring states today, so pressure stays in bar (conventions.md) and a bare1.2forpressuremeans 1.2 bar. A string is parsed as a decimal mantissa and a unit; the unit’s dimension must match the keyword’s, so"125 um"forpressureis refused. The conversion is exact in decimal (a power-of-ten prefix shifts the exponent, any other factor multiplies exactly, in a local context with enough digits) and is rounded once to the nearest binary64 value, so"800 nm"gives the literal800e-9that a script writes. Writing a value back chooses a prefix in which the shortest decimal representation of the double is exact, so the round trip is the identity. Keywords whose elements carry different units (phasesis \([\mathrm{rad}, \mathrm{s}, \mathrm{s}^2, \dots]\),betasis \(\mathrm{s}^n/\mathrm{m}\) by order, the(z, p)form ofpressurepairs metres with bar) have a unit per element or per form (S9). Temperature accepts K only.Beam tilt. A tilt \((\theta_x, \theta_y)\) multiplies the launched field by \(\exp[\mathrm{i}\,k(\omega)(x\sin\theta_x + y\sin\theta_y)]\), \(k(\omega)\) the medium’s real wave number at the launch plane, as
coupling.Alignmentdefines it. With \(k(\omega)\) rather than \(k(\omega_0)\) every frequency leaves at the same angle: a pure tilt with no angular dispersion. Tilt and displacement apply at the launch plane, after anypropz. The screen depends on frequency, so the field is no longer a product of a transverse and a temporal part, and the energy is set after it. The transverse wavenumbers \(k(\omega)\sin\theta\) must lie inside the grid’s transverse band at every active frequency, the tilted and displaced beam must be negligible at the window’s edges, and the pulse-front delay of about \(x\sin\theta\,n/c\) across the beam must fit in the time window; otherwise the launch is refused.Launch normalisation. On a basis whose modes are flux-orthogonal (a diagonal flux metric, as for the Marcatili modes), the energy of a launch is the sum of the modes’ energies, and fractions of the pulse energy are physical. On a general modal basis the flux metric \(M\) has cross terms, and the energy of coefficients \(a\) is \(a^\dagger M a\), which depends on the relative phases. There a launch gives relative amplitudes and phases, and the coefficients are scaled so that \(a^\dagger M a\) is the pulse energy; the per-mode shares are coordinate quantities, not an energy split (S5).
5. Proposed interface#
5.1 S1 — solprop.recipe#
A recipe is a TOML document with five fixed tables. No other top-level name is allowed, so a future keyword can never collide with the format.
Table |
Holds |
|---|---|
|
|
|
the keywords of the route’s |
|
|
|
values set from a design target |
|
a scan over paths |
[recipe]
format = 1
route = "capillary"
title = "RDW in a helium capillary"
solprop = "0.2.0"
[build]
radius = "125 um"
length = "1 m"
mode_average = true
gas = "He"
pressure = ["0 bar", "1.2 bar"] # a gradient, as the keyword accepts
temperature = "293.15 K"
plasma = true
wavelength = "800 nm"
fwhm = "7.5 fs"
energy = "275 uJ"
wavelength_limits = ["100 nm", "1500 nm"]
trange = "200 fs"
[execution]
fields = "planes"
field_planes = [0, -1]
A build keyword is a value of [build] whether or not it is a table: [build.plasma] is
the plasma keyword’s mapping, and [[build.pulses]] its list of pulses. Grouping for
reading is a front end’s business, from the metadata’s group (S9).
Refused keys.
output,progress,progress_period,resume,stop,snapshotsandtdse_archivechoose files, terminals and resume behaviour, which the caller or the command line supplies. A recipe holding one is refused, naming it.prop_radial,prop_freeandprop_planarunpackabsorberandabsorber_schemeonto their settings; a recipe uses thefrom_*form, so those go under thesettingsobject.from_waveguide’s ownmemory_budgetadmits a guide’s tables at build and is a build keyword;[execution] memory_budgetadmits the run. They share a unit and nothing else.Only what was set is written. A keyword left out takes solprop’s default at build time, so a recipe follows solprop’s improvements. What ran is recorded in the output beside it (S2).
None. TOML has no null, and leaving a key out already saysNonewherever that is the default. Only the keywords whose default is notNoneneed a spelling,noiseandboundary_checkon the keyword routes, and those write the string"none". The metadata lists them, and a test checks that each accepts no"none"of its own.[execution] memory_budget = "none"keeps its existing meaning, no check.Numbers. A number is a TOML number in the keyword’s SI unit or a quantity string (section 4). A complex number is
{re = 1.45, im = 0.0}. An integer too large for TOML’s 64 bits, such as a drawn noise seed, is a decimal string; any integer keyword accepts one.Arrays. A one-dimensional numeric array (
betas,phases,save_planes,StatsSpec.energy_windows) is a list of up to 10 000 numbers, with the metadata’s unit per element. A larger one, or a larger-dimensional one, is a data file (below).Objects. A value that is an object (a pulse, a gas mixture, a coupling, a profile, a guide or mode provider, and on the object routes the grids, spaces, media, beams, responses and settings) is an inline table or array of tables with a
typekey naming an entry in a fixed registry insolprop.recipe, and that entry’s keywords:pulses = [{type = "GaussianPulse", wavelength = "800 nm", fwhm = "30 fs", energy = "1 mJ"}]. An entry is a named factory, a constructor, a class method or a module function, with its keywords declared and any build context it receives declared too. The context is what format 1 defines a factory may be given, the route’sgrid. Soabsorber = {type = "absorber"}undersettingsbuildsboundaries.absorber(grid), and{type = "PressureGradient.two_point", ...}and{type = "EnvelopeGrid.from_limits", ...}are entries as well. Only registered types load: a recipe never imports a module or evaluates code. The registry starts with the documented user-facing types (phase 4 lists them), because every keyword of an entry becomes public: adding an entry later is additive, and removing one is not. A type outside it is the Python export’s job.A recipe is one-way. A recipe is built into objects, and objects are never turned back into tables, because they do not keep what made them: a
PressureGradient.two_pointholds positions and pressures, and anEnvelopeGridits arrays and not its limits. So a saved recipe always comes from a recipe.Recipe.from_keywords(route, **keywords)builds one from plain values and refuses an object or a callable by keyword, pointing to the table form or the Python export.What a recipe cannot hold. Callables (
beta,loss, a mode’s loss function), and types not in the registry, such asIndexTableand thebetatuple form in format 1. The Python export is the way out.Data files. A data file is referred to by a path relative to the recipe, with a SHA-256 recorded beside it and checked on load:
{file = "table.npy", sha256 = "…"}for an array, read with pickling disabled, and{type = "load_spectrum", file = "spectrum.csv", sha256 = "…", energy = "1 uJ"}for a measured pulse (S12). The path, after symbolic links are resolved, must lie inside the recipe’s directory unless the caller allows otherwise.Parameter paths. Every value has a canonical path: the keyword, then
.namefor a keyword of an object,[i]for an element of an array and["key"]for a key of a mapping, as inpressure,pulses[1].energyorlaunch["HE1,1:even"].share. In[pins]and[scan]a path is a quoted TOML key where it needs to be. Paths are whatRecipe.setandRecipe.gettake, what errors and warnings name (S10), what a scan varies and what a pin sets. The metadata of a path is that of the keyword it ends on (S9).Pins.
[pins]sets a whole value from a design target instead of a literal:pressure = {target = "zdw", wavelength = "800 nm", search = ["0.1 bar", "10 bar"], root = "lowest"}. A pinned path may not also be set literally, and an element of an array cannot be pinned.Target
Keys, beside
searchandrootPins
Solved
zdwwavelengtha parameter of the guide or fill
on the launch mode at the launch position
rdwrdw, and optionallypeak_powerwithfwhm,shapeandnonlinear_phasethe same
the same; the pump is the recipe’s
wavelengthsoliton_orderordera pulse’s
energyorpeak_poweron the launch mode at the launch position, with the fill’s Kerr coefficient
Pins resolve in two fixed stages, after the literals: at most one pin on a parameter of the guide or fill, then the pins on pulses, which read it. So no dependency order is needed and no cycle can be written. A pressure gradient cannot be pinned, because the target needs one mode. An unreachable target is an issue on the pinned path with the attainable range.
rootchooses among several roots ("lowest","highest"or an index).Recipe.pin_roots(path)returns the solver’sDesignRoots, andRecipe.resolve()returns the recipe with the pins replaced by their values. A design that sets two values at once, such ascapillary_design, is not a pin: a front end calls it and writes its values as literals. The Python export writes the solver call before the route’s call.Scans.
[scan]requiresname, which sets durable filenames and the derived seeds, and may givebase_seed; the other keys are paths and their values (a list, or{start, stop, num, spacing = "linear" | "log"}).Recipe.scan()returns aRecipeScanwith theScan, a picklable builder that passespoint.seedand re-solves the pins at each point, and the keywords forrun_scan, which takesfieldsandfield_planesitself and refuses them on a point’s settings.Examples. The recipes of the guide’s tutorials and gallery examples ship in the package:
Recipe.examples()lists them andRecipe.example(name)loads one. A front end starts a new setup from one of them, so a starting point such as Kerr and plasma on in free space (where the route’s own default is linear) is solprop’s named choice, not the front end’s. Each is tested as the gallery example it mirrors.Format versioning.
formatis an integer. The loader upgrades older formats and refuses newer ones; unknown keys are refused, naming the key and the nearest valid one. Format 1 becomes public when this SEP is merged.
The Python surface:
from solprop.recipe import Recipe
recipe = Recipe.load("rdw.toml") # or Recipe.loads(text), Recipe.from_dict(...)
recipe.set("pressure", "1.5 bar") # validated against the metadata (S9)
recipe.set("pulses[1].energy", "10 uJ")
problems = recipe.check() # list of RecipeIssue (S10), empty if fine
simulation = recipe.build() # a Simulation, not run, carrying its recipe
output = recipe.run("rdw.h5") # build, then run with [execution] applied
recipe.to_python() # a plain script with the route's call
recipe.save("rdw-1.5bar.toml")
Recipe.from_keywords("capillary", radius=125e-6, ...)
recipe.run is build followed by run, with memory_budget passed on; simulation.run
given a simulation from build records the recipe too, and takes the budget as usual. Two
recipes are equal when their values are equal. A recipe keeps the text it was loaded from:
save writes that text verbatim while the recipe is unchanged, comments included, and
writes canonical TOML once it has been edited, which keeps no comments and spells each
quantity in the keyword’s display unit (S9), in ASCII (um; µm is read too).
And the command line (a solprop console script and python -m solprop):
solprop run rdw.toml -o rdw.h5 [--progress log] [--resume]
solprop check rdw.toml # validate and build; print issues; exit status
solprop export rdw.toml --python # print the equivalent script
solprop resume rdw.h5 [--interpolated | --force] [--memory-budget ...] # S2
solprop scan … # the existing python -m solprop.scan, unchanged
to_python() writes the route’s prop_* call when every key is on its surface, and
otherwise Simulation.from_*, the settings and run; keyword arguments in SI, one comment
per keyword stating the recipe’s quantity string, and nothing that a reader would not write
by hand. Execution settings are part of what it writes, and the tests compare them, because
the hash does not cover them.
5.2 S2 — the recipe in the output, and resume from a file#
A Simulation from Recipe.build carries its recipe. with_input, with_mixture and a
change of a setting outside [execution] drop it and warn, so a file never holds a recipe
that does not describe its run. Nothing is compared at build, so building costs no
translation. At the first run start the driver writes a top-level group recipe in the
output, once, holding:
authored: the recipe’s text as loaded, or its canonical TOML if it was edited;resolved: the recipe to rebuild from, also TOML:the authored keys, with each pin replaced by its solved value and the noise seed that was drawn;
each keyword the metadata marks resolvable (S9), at the value used;
[execution], from the settings at run start.
the recipe format and the SHA-256 of each text;
the data files the recipe refers to, under generated names, up to 16 MiB in all; a larger one is recorded by its path and hash only.
resolved is defined by what rebuilds the run, not by “every keyword”. A keyword is
resolvable when its value, written back, gives the same record, and a sync test holds each
one to that. rtol, atol, dz_max, integrator, stats, temperature, plasma,
thg, raman, step_planes, save_planes, loss and model were measured to qualify on
the keyword routes. These do not, and stay left to the build:
dz_init(an explicit first step turns the start-up rule off, which movesstepper.limits.auto_init);raman_fraction(the record says where the fraction came from);the default
absorberandcladding(no table form);backend, which stays as requested, so a run can move between machines;the alternatives
gridandwavelength_limits, andfwhmandwidth, of which only the authored one is written.save_planesreplacesn_save, whose planes it holds.
So a later solprop’s default cannot move a resolvable keyword, and for the rest the resume
gate is what notices. The group is provenance outside configuration_hash, so it needs no
schema change (SEP 42 §5.8): a reader that does not know it ignores it, and a file without
it is a file from a script. It is never rewritten. A later session that differs, such as
solprop run edited.toml --resume with a changed rtol, is recorded in that session’s
execution record (SEP 42 §5.3), and solprop resume afterwards returns to the first
session’s numerics, with the warning SEP 42 gives for a numerics change.
Recipe.from_output(path, resolved=False) reads the authored recipe back for editing, or
the resolved one to show the setup that ran. Saving such a recipe writes its embedded data
files beside it under generated names; a file that was only referred to stays a dangling
reference, and check says so. solprop resume run.h5 (or resume_file(path, mode=True),
with mode as resume takes it: True, "interpolated" or "force") rebuilds the
simulation from the resolved recipe and continues the file through that same resume=, so
SEP 42’s gate decides, by its comparison and with its record. A file written without a
recipe resumes as it does today, from the script. A scan’s file stores the authored recipe
too, in the same group, so a scan reopens with its setup.
Received files. Resuming a file runs the setup it holds, and the guide says so. The limits:
the recipe passes the same checks as a loaded one (registered types only, no keys that choose paths);
embedded data files are read into memory and written only under generated names;
resume_fileignores the recipe’s ownmemory_budgetandnode_budget, and applies the caller’s, refusing rather than warning by default, because a received file must not choose its own limits;parsing an HDF5 file is itself an exposure of the HDF5 library, which the guide states.
5.3 S3 — a plane observer#
A new optional protocol beside InterruptibleReporter and SummarisingReporter:
class PlaneObserver(ProgressReporter, Protocol):
def observe_plane(self, z: float, plane: PlaneView) -> None: ...
The specific name avoids reporters that already have an observe attribute, and
NullReporter does not implement it, so a quiet run builds no view. The driver calls
observe_plane once per save plane, after the plane is committed to the output and before
plane(). That includes the entrance, which has no plane() call of its own. A resume does
not deliver earlier planes again; a front end reads those from the file.
PlaneView is lazy. It holds the plane’s position, the grid, the statistics row and a
description of the basis (S16: whether its quantities are physical flux or coordinate
quantities, and in which units). Its methods compute on request: the frequency- and
time-domain field, by mode where modes exist, and the spectral density. On a host backend
nothing is copied until asked. On a device backend, a plane whose field the output keeps is
already on the host; on a field-free plane (fields="none" or "planes", the scan
default), asking for the field fetches one plane from the device, and the driver counts that
transfer in the run’s resource summary. The arrays are valid only for the call; an observer
copies what it keeps. The existing rule holds: nothing an observer does may change the
propagation.
Failure and workload. An exception from observe_plane, other than RunTerminated and
KeyboardInterrupt, does not fail the run: the driver logs it once with its traceback,
warns, and calls the observer no more in that run; plane, finished and interrupted are
still delivered. The observer runs in the march’s thread and solprop bounds nothing about
its time; the driver reports the time spent in observers in the resource summary. The guide
gives the pattern a front end uses: reduce the plane to what it draws, put it in a bounded
queue that replaces an undelivered preview, and return, keeping terminal and error events on
a reliable channel. Transport and disconnection are the front end’s; solprop guarantees only
the above.
The boundary monitor needs no hook. It warns once with a BoundaryWarning or stops the run
with a BoundaryError, and a front end has both, with parameter set (S10).
5.4 S4 — scan progress#
run_scan(..., progress=) accepts a ScanReporter as well as its literal choices:
scan_started(n_points), point_started(index, point), point_finished(index, status, wall), scan_finished(), and optionally point_reporter(index) -> ProgressReporter | None
for per-point progress.
The local executor calls
point_reporterin this process.A pool reports
point_startedwhen it dispatches a point andpoint_finishedwhen it collects it. Per-point progress needs a new message on the worker pipe, carryingzand thePlaneSummaryof each plane (never a field), at the progress period.Batch, queue and Slurm executors report point boundaries only, from the collected files.
5.5 S5 — launch into chosen modes#
prop_capillary and prop_waveguide gain launch, a mapping from mode label to a share
and, optionally, a phase in radians: launch = {"HE1,1": 0.9, "HE1,2": {"share": 0.1, "phase": 1.57}}.
Labels resolve as
channelresolves them, by display label or index; a label that leaves a parity open when both polarisations are present is refused, naming the candidates. A label not in the mode set is refused, naming the available ones.Shares. On a flux-orthogonal basis (section 4) the shares are energy fractions, normalised to one. On a general modal basis they are relative amplitudes squared, scaled with the phases so that the flux-metric energy is the pulse’s; the guide and the run’s metadata say that the split is then a coordinate one.
Per pulse.
launchis either one mapping, for every pulse, or a list of mappings, one per pulse inpulses, so a pump and a seed can go into different modes.With the other launch keywords.
launchgeneraliseschannel: giving both is refused.incidentwithcouplingcomputes the launch from a beam and is exclusive withlaunch.Simulation.with_inputremains the way to launch an array.The default,
None, launches as today.
The launch is recorded in a new configuration entry, modal_launch, holding each pulse’s
labels, shares and phases. configuration["launch"] already holds an incident beam’s
report, and SEP 42 ignores configuration.initial where a pulse is recorded, so without
this entry two recipes with the same pulse and different launches would continue each
other’s files. The entry is compared under SEP 42, and is absent from older records through
a neutral entry.
5.6 S6 — beam displacement and tilt#
SpatialPulse gains displacement=(d_x, d_y) in m and tilt=(theta_x, theta_y) in rad,
both defaulting to zero, with the names and definitions of coupling.Alignment (section 4).
They are recorded as those two fields of configuration.pulse, compared under SEP 42, with
a neutral entry of zero for older records. They are applied when the beam is built on a
Cartesian grid. A nonzero value is refused on a radial grid, which it breaks the symmetry
of, and so on prop_waveguide(incident=...), which samples the beam radially, and a nonzero
y component on a line grid, which has no y. coupling.Alignment stays the way to
misalign a beam into a guide. The tilt’s validity is the band, edge and delay conditions
of section 4, checked at build; Alignment’s model_error budget, which certifies a
paraxial screen against a guide’s modes, does not apply to a free-space launch.
5.7 S7 — design solvers for every guided route#
A general solver and thin named wrappers:
tools.solve_parameter(build, search, target, *, z=0.0) -> DesignRoots
# build(value) -> mode, or (mode, gamma) where the target needs the fill's Kerr
# target: ZDW(wavelength)
# | RDW(pump, rdw, peak_power=None, fwhm=None, shape="gauss", nonlinear_phase=...)
# | SolitonOrder(order, pulse)
tools.radius_zdw(gas, pressure, zdw_wavelength, ...) # new
build(value) returns a mode for a trial value of the parameter (radius, pressure, core
size, wall thickness), so one solver serves the capillary, antiresonant, rectangular and
slab modes. A mode need not know its fill’s Kerr response, so a nonlinear target needs
build to return it as well. The solver samples the target over the search range
(logarithmic and linear, as pressure_rdw does), discards sign changes through a pole,
refines each bracketed root, and returns every root found, with the attainable range
when there is none. For RDW and SolitonOrder this reuses what pressure_rdw does. For
ZDW a sampled search over the parameter is new, since pressure_zdw brackets once, and it
costs up to 258 mode builds on a non-Marcatili guide. RDW reproduces pressure_rdw beyond
linear phase matching, which needs fwhm and nonlinear_phase beside peak_power. The
existing capillary functions keep their signatures and gain no mode keyword:
solve_parameter is the general form.
5.8 S8 — a capability query#
solprop.capabilities.route("capillary") -> RouteCapabilities
solprop.capabilities.species("N2") -> SpeciesCapabilities
RouteCapabilities lists the route’s keywords (from S9), the allowed values of each
enumerated keyword, and the combination rules of the interface layer (route × keyword ×
species) with their reasons: for example, tdse requires the capillary route and a helium
fill; kerr_form requires provider. Each such rule has an id, and the builder that
enforces it raises with that id and parameter set; a lint-style test checks that every
combination refusal, marked as one at the raise, names a listed rule. Refusals that depend
on values (grid coverage, node budgets, material validity) are not rules; check() finds
them (S10). SpeciesCapabilities lists the ionisation models with their status (curated,
provisional, unavailable), whether a Raman response exists and its status, and the validity
ranges optmat records.
5.9 S9 — parameter metadata#
A table beside each interface function and each registered recipe type, one entry per keyword:
SI unit (or none), and a unit per element or per form where elements differ (section 4);
a list of forms, because many keywords take several (
pressurea number, a gradient, a table or a profile;plasmaa boolean, a rate or a per-species mapping), each with its kind (number, integer, boolean, choice, quantity array, mapping, object), its units and its allowed range or choices;whether it needs the
"none"spelling (§5.1), its recommended display unit, in ASCII (Q7), and whether it is resolvable (§5.2);default as documented (the function’s default stays the single source; the table refers to it), and, where the default is
Noneresolved by a rule (rtolby route,plasmaandthgbyenvelope,step_planesby TDSE,ramanby species,pressureat one bar), a resolver that returns the value that will be used with the rule’s text;group (
geometry,medium,nonlinear,input,grid,numerics,output), level (basicoradvanced), a one-line summary, and the anchor of its manual section.
solprop.recipe.parameters("capillary") returns it. A test checks that the table and the
signature name the same keywords and that each default matches, for the from_* surfaces,
the prop_* surfaces and the execution settings. A second test writes each resolvable
keyword back and checks that the hash holds. A front end’s tooltips and field validation
come from this table; its own labels and wording are its business.
5.10 S10 — structured validation#
SolpropError gains an optional parameter attribute (a path, or a tuple of paths for a
conflict) and an optional rule (S8), set wherever the interface layer refuses a value. The
warnings that concern a value (MaterialRangeWarning, BoundaryWarning, the
provisional-Raman warning) carry parameter too. No exception changes class.
Recipe.check() collects every issue it can at the metadata level (type, unit, dimension,
range, unknown key, refused key), solves the pins, then builds the simulation and translates
it, stopping before the backend is constructed, and reports the first refusal of the builder
or translation with its parameter. Translation finds the value-dependent refusals; it
costs about a tenth of a second on the capillary example, and a pin some tenths more on a
Marcatili capillary, so a front end calls it on a pause in typing, not on every keystroke.
solprop.grid.suggest(pulses, route=...) proposes wavelength_limits and trange from the
input pulses and the expected broadening, labelled as a starting point.
5.11 S11 — a cost estimate#
Simulation.estimate() returns:
the grid sizes;
the persistent memory, from
solprop.driver.memory, admitted against the budget before anything is allocated (a run that would not be admitted is reported, not attempted);the output file size for the requested planes;
the cost of one right-hand-side evaluation on the chosen backend, timed after one untimed warm-up call (so compilation and transform planning are excluded and reported separately), labelled with the backend and thread count;
the integrator’s calls per step, and, for fixed schedules, the total.
It does not predict the number of adaptive steps, which depends on the physics.
5.12 S12 — measured-pulse loaders#
solprop.pulses.load_spectrum(path, *, energy, axis="frequency", axis_unit=None, columns=None, phase_unit="rad", phase_convention="optics", wrapped=None, floor=1e-4) reads
a whitespace- or comma-delimited text file and returns a DataPulse. The default layout is
the usual output of a measurement and retrieval, the same three columns Luna.jl’s
DataPulse documents:
frequency in Hz;
spectral power density, in arbitrary units, because
energysets the scale;spectral phase in radians; a file with only two columns has a flat phase.
axis="wavelength" applies the Jacobian between wavelength and frequency densities, and
axis_unit defaults to the axis’s own SI unit. columns picks other columns. A descending
axis is sorted. phase_convention="fft" reads the opposite sign: solprop’s interface uses
the optics convention (conventions.md §2), so a delay \(\tau\) is \(+\omega\tau\) here, while a
file written for Luna.jl holds the FFT convention, in which the same delay is \(-\omega\tau\).
The phase needs care when it is regridded:
It is interpolated as given. A cubic spline runs through the file’s unwrapped phase, as
DataPulse.from_spectral_densitydoes, not through a phase re-derived from the complex spectrum. Wrapping an unwrapped phase and unwrapping it again cannot recover a change of more than \(\pi\) between samples.A wrapped column is recognised. With
wrapped=None, a column whose values all lie within a range of \(2\pi\) is taken as wrapped and unwrapped, and any other is left alone;wrappedsays which where that guess would be wrong. A phase in degrees needsphase_unit="deg", or it spans 360 and looks unwrapped.The noise floor does not steer the unwrapping. A wrapped phase is unwrapped only over samples above
floortimes the peak power, and held at the nearest such sample beyond them, where the measured phase means nothing. An unwrapped column is taken as it stands.Undersampling is said. A wrapped column whose steps at significant power cluster near \(\pm\pi\) cannot be recovered, and is warned on.
Tool-specific export formats are left until someone needs one; the columns keyword and
TimeDataPulse already cover most of them. There is no load_retrieval.
5.13 S13 — geometry drawings in the library#
solprop.plotting.geometry draws the cross-section of each guide and free-space grid
(draw(ax, guide), thumbnail(route, representation, size)), with an explicit palette
argument instead of CSS custom properties, so the drawings render in Matplotlib, in SVG
viewers and in Qt. The user guide’s figure script calls it, so the guide and the library
draw the same geometry.
5.14 S14 — route keywords#
The keyword surfaces of the keyword routes differ in places. This SEP adds the missing keywords where the feature applies and documents the deliberate differences; it renames nothing and changes no default.
Keyword |
On |
Proposal |
|---|---|---|
|
gnlse, capillary |
add to waveguide |
|
gnlse |
add to capillary and waveguide where a spectral collar exists |
|
all but planar |
a documented difference: a planar grid carries no noise draw, and adding one means declaring a photon measure, which is physics |
|
|
different gates with one unit, documented: the guide’s tables at build, the run at |
|
same behaviour on gnlse (no TDSE); write |
|
integrator and |
deliberate (per-route qualification); exposed through S9’s resolvers and documented in one table |
|
|
deliberate, kept (Q3): |
|
|
different meanings (a switch or model vs a number); keep both, documented |
|
|
deliberate, kept (Q3): the GNLSE has no medium to read a response from, so |
These new keywords map onto settings the run already records.
5.15 S15 — the writer-lock sidecar#
Each HDF5 output keeps a *.writer.lock file beside it. It persists by design: removing a
lock file after releasing an advisory lock races with a second writer opening it. The
sidecar is documented under snapshots; this SEP moves that text to the output guide’s
description of the file, and leaves the behaviour unchanged. Front ends hide it.
5.16 S16 — result capabilities#
solprop.capabilities.output(path_or_output) -> OutputCapabilities
OutputCapabilities says what a results file can give, extending the output capability
machinery that exists (CheckpointOutput and the readers’ refusals):
the representation and basis, and for each derived quantity (power, energy, spectral density, field) whether it is a physical flux or a coordinate quantity, with its unit;
which planes keep fields, and which only statistics rows;
which operations are available and which refused, with the reason: compression on spatial and general modal outputs, general modal reconstruction without the guide and reference tables (with the identities the file holds), physical powers from a spatial file;
whether a recipe is recorded (S2) and the file’s resumability (S17).
PlaneView (S3) carries the same description for the plane it shows; it does not make the
same physical operations available on every basis.
5.17 S17 — a portable stop, and resumability#
Simulation.run and run_scan accept stop=: any object with an is_set() method
(threading.Event qualifies). The flag belongs to that run. The driver polls it before the
run’s first step and at every trial step, so it answers within one step or trial, or after
the translation or atomic preparation in progress. When it is set, the run writes its state
as for SIGTERM, tells the reporter through interrupted, and returns the handler: a
requested stop is not an error. outcome.message says that the caller’s flag stopped it,
and resume=True continues. A front end sets it from any thread on any platform; signals
are not needed (on Windows, os.kill with SIGINT terminates a process outright). solprop run maps SIGINT, SIGTERM and, on Windows, SIGBREAK (sent by CTRL_BREAK_EVENT) onto it,
and exits with the usual status.
Across a scan, a stopped point is not committed, and a rerun computes it again:
Executor |
|
|---|---|
local |
polled by the point in flight |
pool |
a message on the worker pipe, which that worker’s run polls; the pool’s abrupt |
batch |
between groups |
queue, Slurm |
refused, naming the queue’s own tools; points run in other processes or on other machines |
solprop.output.resumability(path, gate=False) -> Resumability
Resumability answers three questions separately:
Can the file continue, and from where? The
z, and theresume=value it needs:True,"interpolated"or"force"(a file with a forced session). Otherwise the reason: no work left, no checkpoint written before a failure, another output schema, or a writer still alive.Can it be rebuilt from itself? Whether a recipe is stored, and readable: a newer format is named. No recipe does not mean it cannot continue, only that its script must do it.
Does the gate pass? Only with
gate=True, at the cost of a rebuild: SEP 42’sRecordComparisonfor the stored recipe, so a moved optmat digest shows before a resume is tried.
A front end’s own run states (queued, stopping, stopped) stay its own; RunOutcome.status
is unchanged. A changed grid, a wider time window included, is a new run, not a resume (Q4).
5.18 S18 — previews#
Before a run, a front end shows what a recipe will build without building the run:
Recipe.modes(z=None)returns the mode set a guided route would build (labels,beta,beta2,zdw, effective areas and loss over the grid’s band), at the launch position unlesszis given. Its cost is the mode solve: on a guide route that means the dense tables, with the memory admissionfrom_waveguidemakes.Recipe.preview_input()returns the input pulse, and the beam on free-space routes, on the run’s grid, as the launch will set them (S5, S6), with its energy and peak power, and without noise, so a preview draws nothing.
6. Implementation strategies#
Layer. All of this is interface and driver work above translation (architecture.md section 3). No backend, kernel or stepper changes;
KERNEL_SCHEMA_VERSIONis unchanged, and so is the output schema (section 9).Recipe keys = keywords (recommended) or a designed schema. A designed schema with its own names (
[pulse] shape = "gaussian") reads well, but is a second vocabulary to keep in step with six functions and their docstrings. Using the build keywords themselves keeps one vocabulary, makesto_python()direct, and lets the sync test (S9) catch every drift. A front end may present any names it likes.One
[build]table (recommended) or group tables. Tables such as[geometry]and[grid]read well by hand, butgridandmediumare keywords,outputis a refused key, and a mapping such as[plasma]looks like a group. One table has none of these problems, and it needs no list of reserved words as keywords are added.Metadata: a table (recommended) or
Annotatedsignatures.Annotated[float, Unit("m"), Range(0, None)]puts metadata in the signature, but every interface function is already long, the type checker and the runtime array-shape checks would see the annotations, and the documentation renderer shows them. A table beside each function, checked against the signature by a test, costs less and changes no signature.TOML reading and writing.
tomllib(standard library) reads, andtomli-wwrites (Q6). It is pure Python with no dependencies of its own, MIT-licensed, and its wheel is about 7 kB. It joins the core table ofdocs/dev/dependencies.md. It writes no comments, so an edited recipe loses them (§5.1). Tried on this format, it round-trips nested tables, arrays of tables and quoted path keys throughtomllib.Unit parsing. A small parser in
solprop.recipewith a fixed table of SI prefixes, the units the keywords use and their dimensions, converting withdecimal; no dependency on a units library.Plane observer. The view wraps what the driver already holds when it calls
plane(); it computes or fetches only on request.Stop. The flag is polled per trial step, a cost of one attribute call, and it raises the same internal path as SIGTERM, which a prototype showed working from another thread with the run on a non-main thread: it stopped in 34 ms and the file resumed to completion. The run catches it when its own flag set it. It is not held in the process-global signal guard.
Cost. Loading and building a recipe add milliseconds. The plane observer costs only what the observer asks for.
check()costs a translation and the pins. The estimate costs a warm-up and a few timed calls, only on request.
7. Verification and validation#
The tolerance classes are those of the testing policy.
Recipe equivalence (class P, configuration identity). For the examples of the user guide and gallery, a hand-written recipe builds a simulation with the same
configuration_hashas the example’s call, with the seed fixed. The manual’s list of examples that have no recipe (those usingwith_input, callables or an unregistered type) is checked too, so it stays true. The keyword routes in phase 1, the object routes and the waveguide in phase 4.Python export (class P).
to_python()of each of those recipes, executed in a subprocess with the run replaced by a build, gives the same hash and the same execution settings, which the hash does not cover.Round trip (class P). Table → text → table returns an equal recipe; each registered type goes table → object → hash; a recipe loaded and unchanged saves verbatim; formats are upgraded by tests holding a recipe of each older format.
Resolved recipes (class P). Each keyword the metadata marks resolvable, written back, keeps the hash. A rebuild from
resolvedhas the file’s hash, seed included. The §5.2 list of keywords that are not resolvable is tested as such, so a keyword that becomes resolvable is noticed.Quantities (class P). Each unit and prefix converts to the literal a script writes, including the section 5.1 example’s values; writing back is the identity; a wrong dimension and an ambiguous string are refused. A complex number, a 128-bit seed as a decimal string, and a list at and over the array limit.
Provenance. A file written from a recipe holds both texts; a simulation changed after its build by a mutator or a setting outside
[execution]writes no recipe and warns; a change offieldsis written as it was at run start; an unseeded resume through_adopt_recorded_seedneither warns nor rewrites the group; a file of the current schema with and without the group is read by the readers; a scan’s file holds its recipe.Resume from a file (class T). A short capillary run stopped by the
stopflag after a few planes, and another stopped by SIGTERM, resumed withsolprop resume, agree with an uninterrupted run within an accuracy envelope against a tighter reference, set per case in a comment.resume_filetakes each mode.resumabilityis checked on a complete file, a stopped file, an interpolated last plane, a failure before the first checkpoint, a forced file, a live writer and a newer recipe format, andgate=Trueshows a moved digest.Stop. The flag stops before the first step, between trials and on a non-main thread; two runs in one process stop separately; the run returns and resumes. Each executor as §5.17 says.
Sync. Metadata tables match the signatures (names, defaults) on every surface; every marked combination refusal names a listed rule; the keywords with the
"none"spelling are those whose default is notNone, and each accepts no"none"of its own.Observer. The observer’s copy of each plane equals the plane read back from the file (class P). A fixed-schedule run with an observer that asks for every view agrees with the run without one (class R). The entrance is observed, and a resume does not repeat planes. A failing observer is detached and the run completes; a stop raised from an observer stops the run. Both on
numpy_ref,numba_cpuand the native build, field-free planes included.Estimate. On the three backends, a budget below the persistent memory is reported without allocation, and the reported memory agrees with
solprop.driver.memory.Pins and scans. A pinned pressure equals
pressure_zdwfor the same target, where it has a single root; a scan re-solves its pins per point; two mode-parameter pins, a pinned gradient and an unreachable target are refused.Recipe.scan()’s keywords carry[execution]torun_scan.Design solvers.
solve_parameterwith the Marcatili builder reproducespressure_rdw(withpeak_power,fwhmand the nonlinear phase) to its tolerance,pressure_zdwwhere it has one root, and finds both roots where a resonance gives two;radius_zdwinvertszdw.Loaders. The test file reads to the stated energy within the quadrature error of its sampling, and to the centre wavelength within one sample spacing; a wavelength-to-frequency conversion conserves energy to the same bound. A delay of \(\tau\) read in each
phase_conventionputs the pulse at \(\pm\tau\). A large chirp in an unwrapped column survives, a wrapped column unwraps with a noise floor beside it, degrees are read, and an undersampled wrapped column warns.Launch. On a Marcatili basis the per-mode energies equal the shares; on a general modal basis the flux-metric energy equals the pulse energy. Two recipes with the same pulse and different launches refuse each other’s files under SEP 42’s gate.
Beams. Displacement and tilt agree with
coupling.Alignment’s definition; each frequency leaves at one angle; the energy after the screen is the pulse’s; each refusal of section 4 and §5.6 fires. A record without the fields continues through its neutral entry.Previews and capabilities.
preview_inputequals the run’s noise-free initial field;modesequals the built mode set. Each refusalOutputCapabilitiesstates matches the reader’s actual refusal, so the two cannot drift.Safety. A recipe naming an unregistered type, holding a refused key, or reaching a path outside its directory (symbolic links resolved) without an explicit allowance is refused; an embedded data file is never written at its stored name, and the cap holds; a received file’s own budgets are ignored.
8. Documentation#
A new guide page, Recipes and the command line (
guide/setup/recipes.md): the format, quantities,None, paths, objects, data files, pins, scans,solprop run,check,exportandresume, with the capillary example above, and the examples that have no recipe, with why.Resuming: resume from a file alone,
resumability, the portable stop, and that resuming a received file runs its setup.Progress:
PlaneObserver, its failure policy and the coalescing pattern, how the boundary warning reaches a front end,ScanReporter.Pulses and beams: loaders, mode launch (and its meaning on general bases), displacement and tilt.
Design tools: the general solver,
radius_zdwand pins.Output: the recipe group, result capabilities and the writer-lock sidecar.
A table of route defaults (integrators, tolerances) with the reason for each difference.
Docstrings for every new name; a gallery example that loads a recipe, edits a value and runs it; the example recipes listed on the recipes page.
9. Compatibility and impact#
Python interface: additive. Every name is new, every new keyword defaults to today’s behaviour, and the new reporter calls are optional protocols with names no reporter is likely to have (adding them to
ProgressReporteritself would break third-party reporters and is not proposed). No exception changes class.Output schema: unchanged. The recipe group is a top-level group written once, and outside the hash, so files written before and after phase 2 are read and resumed alike (SEP 42 §5.8). S5 and S6 add recorded fields, each with a neutral entry and compared under SEP 42. So this SEP lands after SEP 42’s phase 1 and bumps nothing.
Recipe stability. Recipe
format = 1becomes a public format when this SEP merges. Renaming a keyword later then needs a format upgrade rule as well as a changelog entry. Every keyword of a registered type is part of the format too, so the registry starts small. The route vocabulary was aligned (wavelength,shape) before the first release, and S14’satolandramanrows stay as they are (Q3).Two choices that would be breaking, and are not recommended: renaming keywords to one vocabulary across routes where the meanings differ (
loss/loss_db_per_m), and aligning integrator or tolerance defaults across routes (the per-route defaults were qualified separately).Validated results: unchanged; no computed field changes.
Dependencies:
tomli-w, to write TOML (section 6, Q6).
10. Alternatives considered#
The recipe in the front end only. Simpler for solprop, but scripts, batch jobs and results files would not share it, the front end would copy solprop’s defaults and rules, and resume from a file would still be impossible.
HDF5 as the setup format. The output already holds the resolved configuration, but it is not editable by hand, not diffable, and cannot rebuild a simulation. TOML is for setups; HDF5 embeds the TOML.
JSON or YAML. JSON has no comments and is noisy to edit; YAML’s implicit typing turns values such as
noor1e-5into surprises, and the standard library cannot read it. TOML has a standard-library reader and explicit types.Pickled simulations. Not readable, not stable across versions, and unsafe to load.
One SEP per change, or two SEPs (the recipe; the front-end hooks). The changes share one purpose and one test strategy (the recipe round trips), and the hooks name recipe paths, so splitting would publish recipe format 1 before the pins, paths and previews it must describe. One SEP, on one branch, lets the format freeze once.
Turning objects back into tables (a
Recipe.from_call). It would need every object to keep what made it, or a factory to attach its table, and would be wrong for hand-built objects. A recipe always comes from a recipe, or from plain values.A resolved recipe holding every keyword. Simple to state, but it does not rebuild its run: values read back from a default build move the hash or have no table form (§5.2).
Comparing hashes to detect a simulation changed after its build. It costs a translation at every build, gives a false alarm on an unseeded resume and misses a changed
fields. Mutators dropping the recipe are cheap and exact.A boundary hook on the observer. The monitor has no verdict to give: it warns once or stops, and a front end has both.
A rule language for cross-field validation. Instant validation of every rule would need the value-dependent refusals of translation written a second time.
check()reuses them; only the combination rules are listed (S8).Signals for stopping. Portable only on POSIX; a flag is portable and testable.
11. Phases#
The phases are made on one branch and the SEP reaches main in one pull request when its
last commit sets Final (SEP 0, Implementation). The branch starts after SEP 42’s phase
1, whose gate S2 and S17 rest on. Phase 4 follows SEP 40, because it registers the
free-space types, whose interface and default SEP 40 changes. A front end in development
builds against the branch; the order puts what it needs first.
Recipe core on
gnlseandcapillary(S1, S9, S10, S12).solprop.recipewith format 1; quantity parser and TOML writer; paths,None, complex numbers, arrays and data files; the factory registry for these routes’ objects;load_spectrumas its first data-file entry; metadata tables with forms, resolvers and their sync tests;Recipe.load/save/get/set/check/build/run/to_python/from_keywords/example;parameteron errors and warnings; thesolpropconsole script withrun,checkandexport; the guide page. Verifies recipe equivalence, Python export, quantities and the loader’s phase rules on every keyword-route example of these routes.Provenance, resume and stop (S2, S15, S17). The recipe group in the output, the resolvable keywords and their test,
Recipe.from_output,solprop resumeandresume_filewith their modes, thestopflag per executor,resumability; the writer-lock text. Verifies resume from a file after a stop and after SIGTERM, and the received-file limits.Watching runs (S3, S4, S11, S16).
PlaneObserverandPlaneView,ScanReporterand the pool’s progress message,Simulation.estimate(),[scan]tables andRecipe.scan(), result capabilities. Verifies observer neutrality and failure on the three backends, and scan progress on local and pool executors.Object routes, waveguide, launches and previews (S1 continued, S5, S6, S18). Recipes for
waveguide,radial,freeandplanar, with the documented user-facing types registered and listed (guides, mode providers, grids, spaces, media, beams, responses and settings, and the factories such asabsorber);launchwith its record; beam displacement and tilt with their neutral entries;Recipe.modes()andpreview_input(). Verifies recipe equivalence on these routes’ examples, the launch normalisation and the beams.Design, pins and capability (S7, S1’s pins, S8).
solve_parameter,radius_zdw;[pins],Recipe.resolveandpin_roots; pins carried into what already reads values,Recipe.scan(),Recipe.modes(),preview_input()and the resolved recipe;solprop.capabilities.routeandspecieswith rule ids.Drawings and keyword gaps (S13, S14).
solprop.plotting.geometryand the guide figures moved onto it; the missing keywords added; the route-defaults table.
12. Open questions#
Q1. Recipe values: accept both quantity strings and SI numbers, and write quantity strings (recommended), or SI numbers only? Decision (John Travers, 2026-11-01): as recommended.
Q2. Should
[recipe] routebe required, or inferred from the keywords present (recommended: required; inference is fragile betweencapillaryandwaveguide)? Decision (John Travers, 2026-11-01): as recommended.Q3. S14’s open rows: align the
atoldefaults, and giveprop_gnlsethe gas-routeramandefault semantics? Each is a change of default. Recommended: keep both and document them as deliberate; if either is to change, change it in a minor release before this SEP merges, listed as a break (ADR-058), so that recipe format 1 is published with the final defaults. Decision (John Travers, 2026-11-01): as recommended; both stay and are documented as deliberate.Q4. Should
solprop resumealso accept a recipe edited after the run (for example a longerlength), refusing only when the hash differs in more than the run’s end? Recommended: no in this SEP; extending a finished run is its own question. A changed grid is always a new run. Decision (John Travers, 2026-11-01): as recommended. Extending a finished run is #141, simplest after this SEP’s recipes; SEP 42 does not take it on.Q5. Which pulse-retrieval formats does a retrieval loader support first? Decision (John Travers, 2026-11-01): the usual three columns of frequency in Hz, spectral power density and spectral phase, as Luna.jl’s
DataPulsetakes, with care over the phase when regridding. §5.12 now specifies this, throughload_spectrumalone. The phase is in the optics convention by default, with a keyword for the other sign.Q6. Is
tomli-wacceptable as a dependency instead of a subset writer? Decision (John Travers, 2026-11-01): yes, take the dependency; it is light, and there is no reason to rewrite it.Q7. Should the metadata include a recommended display unit per keyword (fs for durations, µm for radii), or is that left to front ends? Recommended: include it, so that recipes written by any front end use the same quantity strings. Decision (John Travers, 2026-11-01): as recommended. Display units are spelled in ASCII in a recipe (
um);µmis read too, and a front end shows what it likes.Q8. If S5 or S6 cannot record its launch through existing records, is one output-schema bump in phase 4 acceptable (listed under ADR-058), or should the feature wait for the resume-compatibility policy of 1.0? Recommended: the bump, made once and in the same minor release as any other schema change. Decision (John Travers, 2026-11-01): avoid the bump through SEP 42, under which an additive record field moves neither the schema nor a resume. SEP 42 is now accepted and comes first, so no bump is needed and the fallback is gone (section 9).
Q9. The shape of format 1. One
[build]table with no group tables (recommended, §5.1, §6), or the group tables the maintainer first approved, with their names changed so that none is a keyword. Decision (John Travers, 2026-11-01): as recommended.Q10. A stop that returns.
run(stop=)returns the handler when its own flag stops it (recommended, §5.17), or raises like SIGTERM, aSystemExitthat a front end’sexcept Exceptionmisses and that ends a worker thread silently. Decision (John Travers, 2026-11-01): as recommended.Q11. One way only. Drop
Recipe.from_callforRecipe.from_keywordswith plain values, and never turn an object into a table (recommended, §5.1), or have factories attach their tables so that more calls can be captured. Decision (John Travers, 2026-11-01): as recommended.Q12. Edited recipes lose their comments.
saveis verbatim until a recipe is edited and canonical after (recommended, §5.1), or preserve comments, which needs a TOML library that keeps them, a heavier dependency than Q6 took. Decision (John Travers, 2026-11-01): as recommended.
13. References#
TOML v1.0.0 specification, https://toml.io/en/v1.0.0
Python Software Foundation,
tomllib— Parse TOML files, https://docs.python.org/3/library/tomllib.htmlPython Software Foundation,
os.kill(Windows behaviour), https://docs.python.org/3/library/os.html#os.killPython Software Foundation,
decimal— Decimal fixed-point and floating-point arithmetic, https://docs.python.org/3/library/decimal.html
Part II — Implementation record#
One report per phase, appended in the pull request that merges the phase (SEP 0 §8).
Start of implementation — 2026-11-01#
A deviation from §11. §11 starts the branch after SEP 42’s phase 1. The maintainer started phases 1–3 earlier, alongside SEP 42 and SEP 40, because most of their work needs nothing from SEP 42’s gate:
phase 1 (the recipe core) sits above translation and adds no recorded field;
phase 3 (watching runs) is driver hook work;
of phase 2, the stop, the writer-lock text and writing the recipe group are independent.
The parts that rest on SEP 42 (resume_file, solprop resume, resumability and the
resumability field of OutputCapabilities) are made after SEP 42’s phase-1 branch is merged into
this one. Phase 4 still follows SEP 40.
Also on this branch. #87 (one table of
route defaults) is phase 1’s first slice, so that S9’s resolvers read that table rather than
copy the rules again. #86 is settled separately
before this SEP is set Final.
Part I amended after acceptance. §5.12 named DataPulse.from_time, which
#70 replaced with the class TimeDataPulse before
this SEP was accepted; the text now names the class.
Phase 1 report — recipe core on gnlse and capillary (S1, S9, S10, S12)#
Complete on 2026-11-01, commits db6e692 to 48889c8 on feature/sep-0041-recipes, with
#87 as its first slice. Checked by the fast checks and the directly affected test modules,
on numpy_ref, numba_cpu and the native CPU build (Apple M5 Max); the full gate runs
in CI on the pull request at Final.
Delivered.
#87, one table of route defaults (
solprop.driver.defaults).ROUTE_DEFAULTSholds each route’s integrator, tolerances, noise policy and collar scheme, with two variants (a capillary beside the TDSE response, a waveguide of slab modes), and pure functions resolve each default.SimulationSettingsmarks what it took from the table, so a copy in the other precision resolves the tolerance and the backend"auto"again. A new record outside the hash,RunMetadata.defaults(DefaultsResolution), the run summary and the settings’ display say which values were defaults. The summary names a Multistep carrier frame. An incident launch keeps a default noise’s resolution, and an interior launch keeps the single-precision reason. The guide says that naming an integrator keeps the interface’s tolerance.solprop.recipe(S1): the quantity parser and writer, parameter paths, the five-table document, the factory registry (24 types, below),Recipewithload/loads/from_dict/from_keywords/examples/example,get/set,check,build,run,to_text/saveandto_python, and thesolpropcommand withrun,check,exportandscan(alsopython -m solprop).tomli-wwrites TOML (Q6).Parameter metadata (S9): one entry per keyword of
from_gnlse(41),from_capillary(61) and the execution settings (4), with forms, units, the"none"spelling, display units, the resolvable flag, resolvers that read the defaults table, group, level, summary and manual anchor. Tests hold it to the signatures, the anchors to the manual’s headings and the resolvers to what the build resolves.Structured validation (S10):
parameterandruleonSolpropError,parameteronSolpropWarning, set at about 105 refusal sites of these two routes;Recipe.check();solprop.grid.suggest. On the capillary examplecheck()costs 0.24 s (a GNLSE 0.01 s) and loading a recipe 1 ms.solprop.pulses.load_spectrum(S12).27 example recipes, each tested to build the configuration hash and execution settings of the gallery example it mirrors, through the recipe and through its Python export run in a subprocess.
Ahead of phase 2, because they touch no SEP 42 code: the stop flag in the backends and
run_problem(S17, withoutSimulation.run’s return yet), and S15’s text.
Deviations and decisions made in the phase.
The modules are public. §5.1 names only
solprop.recipe. Its modules arecore,document,metadata,paths,quantity,registry,exportanderrors, public so the API reference documents them and a front end can call the quantity parser and writer to display values.The registry.
GaussianPulse,SechPulse,load_spectrum,PressureGradient.two_point,PressureGradient.from_stages,Constant,Linear,PiecewiseLinear,Mixture.fill,Mixture.partial_pressures,Mixture.air,EnvelopeGrid.from_limits,FieldGrid.from_limits,StatsSpec,BoundaryCheck,FixedSchedule,FixedSchedule.uniform,Multistep,RungeKutta(without itstableau),LowStorageRungeKutta,absorber(on the grid),TubeLattice,CoherentWallandMaterialSelection. Left out:CustomPulse, aDataPulseorTimeDataPulsefrom arrays,IndexTable, mode objects, a TDSE response and a glass cladding object, which the Python export carries.Decoded lists take the container the parameter’s annotation names, a tuple or an array, so a recipe passes what a script passes; two recipes compare by value, so
"1 cm"and0.01are equal.A value built on the grid (
absorber) is built by building the route once without it to obtain the grid. The export writes it only when the recipe givesgridexplicitly; otherwise it refuses, naming the keyword.The recipe is not yet carried by the
Simulation(§5.2): nothing reads it before the recorded recipe, so it moves to phase 2 with the mutators that drop it.Where
parametercomes from. The small validators of pulses, grids, profiles and the step controller name their own arguments, which the routes rename where a keyword differs (reference_wavelength→wavelength,phase→phases), because a call wrapped from outside cannot tell which of its keywords was refused. Left without a parameter, where it would be ambiguous: object-level checks inSimulation.__init__, the mode and quadrature constructors,Mixture.fill’s own refusals and an unknown backend name. Material warnings raised at translation, the commonest being a gas index used outside its fit because ofwavelength_limits, are not labelled in this phase.load_spectrumacross interior gaps. Below the noise floor inside the band a wrapped phase is interpolated linearly between the samples above it; §5.12 states only the hold beyond the band.grid.suggest’s rule is a documented heuristic, stated as a starting point: ten input bandwidths each side of the carrier, at least half the carrier frequency on thegnlseroute and all of it on thecapillaryroute, and thirty input durations of window beyond the delays.Units the grammar does not hold.
ionisation_field_range(V/m) andloss_db_per_m(dB/m) take plain numbers in their stated units, with no quantity string.Ten gallery scripts on these routes have no recipe, listed with the reason in
tests/recipe/test_examples.pyand checked against the gallery: two phase-matching roots that a pin would express (phase 5), lists of mode objects, mode or table objects as the GNLSEfibre, and runs launched withwith_input.
Phase 2 report — provenance, resume and stop (S2, S15, S17)#
Complete on 2026-11-01. SEP 42’s branch was merged in at its Final (4daf5d8, PR #336 open),
and main after it (e34bdee); the phase’s own commits are e3c2f36 (the stop poll, made in
phase 1), 1f061c7, 52cd5d5, 45b5a9e, 8dfe354 (the scans’ stop) and 1103b21. Checked as
phase 1 was.
Delivered.
The stop (S17).
Simulation.run(stop=)andrun_scan(stop=)take any object withis_set(). Every march polls it at the top of each trial, where the state is the last accepted one: the adaptive and replayed Runge–Kutta marches, the Adams march, the low-storage march and the batch, and so the native backend, which steps through them.run_problemalso polls between planes. A set flag writes the state as SIGTERM does and raisesStopRequested, aBaseException, whichSimulation.runturns into a return when the flag is its own; another run’s propagates.solprop runandsolprop resumeset the flag on SIGINT, SIGTERM and SIGBREAK, interrupt on a second signal, and exit with 128 plus the signal. Scans per the §5.17 table. Local and Pool stop the point in flight, Pool through a"stop"message on the worker pipe, read by a worker-side flag that holds other messages for later. Batch stops between groups, and Queue and Slurm refuse the flag. A stopped point is released to its earlier state rather than committed, so a rerun computes it at once, not afterstale_age.The recipe in the output (S2).
Recipe.buildattaches the recipe.with_input,with_mixtureand a new value of any setting butfields,field_planesandresume_historydrop it with a warning.run_problemwrites the top-levelrecipegroup once (RecipeRecord): the authored text, the resolved text, the format, both SHA-256s, and the data files under generated names, embedded up to 16 MiB. A scan’s file records the authored recipe throughrun_scan(recipe=).The resolved recipe writes back the drawn seed,
[execution]at run start, and each resolvable keyword the authored recipe left out, at the value the build used:rtol,atolanddz_maxfrom the settings;the integrator as
{type = ...}, only when it is its class’s default instance;the statistics as a
StatsSpectable, only without apertures;the save planes, in place of
n_save;the capillary’s temperature from its record, and
ramanfrom whether a Raman response was built;plasmaandthgfrom their resolvers;lossandmodelfrom their defaults.
A test writes each back alone and rebuilds the file’s hash on three examples. Another shows that
dz_initandraman_fractionwould move it.Resume from a file.
Recipe.from_output(path, resolved=False), on a run’s file or a scan’s (throughScanOutput’s schema check), keeps the embedded files, andsavewrites them beside the recipe under their generated names.resume_file(path, mode)andsolprop resumerebuild from the resolved recipe and continue through SEP 42’s gate. The file’s ownmemory_budgetandnode_budgetare ignored. The caller’s apply, by default the machine’s four fifths as a refusal.solprop.output.resumability(path, gate=False). It reports a live writer (by probing the writer lock), another schema, no state, a complete run (by position) and a field-free last plane. It gives theresume=a file needs,"force"from the forced positions and"interpolated"fromResumeState.interpolated, now public. It says whether a recipe is stored, missing or of a newer format, and withgate=Truegives theRecordComparison.S15: the writer-lock sidecar is described with the file on disk.
Deviations and decisions.
The stop is polled at the top of each trial, not in
StepDriver._record. The Adams and replay marches record a step after committing it, and a raise there would have dropped that step’s bookkeeping.A stopped run’s outcome has status
failed, with the message “StopRequested: stopped by the caller’s stop flag”, as §5.17 keepsRunOutcome.statusunchanged.Changing the backend through the settings drops the recipe.
backendis a build keyword, so a recipe that names another backend no longer describes the run.run_scangainsrecipe=, which §5.2’s “a scan’s file stores the authored recipe too” needs.Recipe.scan()passes it among its keywords.
Unmeasured. The stop and the recipe group on the GPU builds, SIGBREAK on Windows,
and Queue and Slurm beyond their refusal.
Phase 3 report — watching runs (S3, S4, S11, S16)#
Complete on 2026-11-01, commits 8dfe354, 5cda2ba, 1103b21 and e5d362f. Checked as phase 1
was.
Delivered.
PlaneObserver(solprop.log), an optional protocol thatNullReporterdoes not implement.run_problemshows each committed plane, the entrance included, beforeplane(); a resume does not repeat planes.PlaneView(solprop.driver.plane_view) holds the position, statistics row, basis and quantity kinds at once. Itsspectrum,field,powerandspectral_densitycall the processing functions on a one-plane in-memory output built on request, so what an observer reads is what the file gives. A field the output does not keep is fetched from the backend only on request.Failure: an
Exceptionfrom the observer is logged with its traceback, warned on, and the observer detached. A stop or an interrupt passes through.Resources: the run records
observer_time_sandobserver_transfers.Tests on numpy_ref, numba_cpu and native: the copy equals the file, a fixed-schedule run with an observer is bitwise the run without, field-free planes are fetched and counted, a failing observer is detached, a stop from an observer stops the run, and a resume shows the rest.
ScanReporter(withWatchingScanReporterfor per-point runs, andNullScanReporter), accepted byrun_scan(progress=); the named choices go through the same path. Pool relays each point’sstarted,plane,finished,interruptedand summary over a new("progress", call, args)worker message carrying a picklablePlaneScalars, never a field. Batch, Queue and Slurm report point boundaries.Simulation.estimate()(solprop.driver.estimate) gives:the state’s shape and the persistent memory, admitted before any allocation; a run refused by its budget is reported and no backend is built;
the output size for the retained fields;
the setup, which is building the backend and taking an untimed warm-up trial step;
the right-hand-side cost, the median of timed trial steps divided by the tableau’s stages, labelled with the backend and its threads;
the calls per step (six, one or four), with their total for a fixed schedule.
On the soliton example: numpy_ref 368 µs, numba_cpu 76 µs and native 77 µs per evaluation, on the Apple M5 Max.
solprop.capabilities.output(path_or_output). It gives the basis, the physical or coordinate kind and unit of power, energy, spectral density and field, the planes with fields and those with statistics only, and the operations refused with the readers’ own text. That text comes from the same gates (refusal_forand the compressors’ basis check), and a test compares each with the reader’s refusal on modal, general modal and spatial files. It also says what a general modal reconstruction needs, and, for a path, whether a recipe is recorded and the resumability.[scan]andRecipe.scan(). Each path’s values or range are read in the unit of its keyword (or of a registered type’s keyword). The result holds a picklable builder that sets each point’s values, passespoint.seedand builds without[execution], and therun_scankeywords.
Deviations and decisions.
The right-hand-side cost is taken from Runge–Kutta trial steps.
_trial_stepis the one call every backend implements; the cost of an evaluation does not depend on the integrator that calls it.ScanReportersplits into two protocols.point_reporteris optional, soWatchingScanReporterdeclares it.The
PlaneViewarrays come from the processing functions, in their units and convention, rather than raw stored amplitudes.
Unmeasured. The PlaneView fetch and the estimate on device backends, and ScanReporter
on Queue and Slurm beyond point boundaries.
State after phase 3. Phases 4 (the object routes, the waveguide, launches and
previews), 5 (design, pins and capabilities by route and species) and 6 (drawings and
keyword gaps) remain. Phase 4 waits for SEP 40 on main.
Phase order after phase 3 — 2026-11-01#
A second deviation from §11, agreed with the maintainer: phase 5 is made before phase 4,
while SEP 40 is still being implemented. Phase 5 does not touch the free-space interface that
SEP 40 changes. The guided half of phase 4 and most of phase 6 follow it. The free-space half
of phase 4, its types, recipes, beams and previews, waits for SEP 40 on main. Pins are
therefore carried into Recipe.modes() and preview_input() when phase 4 adds them, rather
than the other way round.
Phase 5 report — design, pins and capability (S7, S1’s pins, S8)#
Complete on 2026-11-01, commits a3ad93a (capabilities), 59bdf15 (solvers) and
50926af (pins). Checked as phase 1 was. Pins reach Recipe.modes() and
preview_input() with the guided half of phase 4, which adds them.
Delivered.
tools.solve_parameter(build, search, target, *, z=0.0)with the targetsZDW,RDWandSolitonOrder, returningDesignRoots(every root, ascending;pickby"lowest","highest"or an index; the attainable range when there is none).It samples 129 linear and, where the range is positive, 129 logarithmic values, refines each sign change with Brent’s method to a relative 2e-12, and keeps a refined change only where the residual has fallen to a thousandth of its bracket ends’, which discards a change through a pole.
RDWispressure_rdw’s residual, now one shared function, withpeak_power,fwhmand the nonlinear phase.pressure_rdwuses the same sampler.radius_zdw;soliton_peak_powerandpulse_energy, the closed-form inverses, whichcapillary_designnow calls with the same arithmetic.Tests:
RDWreproducespressure_rdw(linear, largest constituent and supplied power) to 1e-12;ZDWreproducespressure_zdwto 3e-8 where it has one root;radius_zdwinvertszdw; two roots on either side of a zero-dispersion point for a soliton order; six roots around a Vincetti wall resonance, alternating in sign; a synthetic pole discarded.
[pins](solprop.recipe.pins).Targets
zdwandrdwsetradiusor a constantpressureof the capillary;soliton_ordersets a pulse’senergyorpeak_power, on the route or on one ofpulses.The guide’s pin is solved by building the route once, at the middle of the search range, and re-evaluating its launch mode at each trial value (a density for a pressure,
at_parametersfor a radius), with the fill’s Kerr coefficient where the target needs it. A pulse pin is closed form on that mode.Recipe.resolve()returns the recipe with the pins written as values, andRecipe.pin_roots(path)the solver’s roots.check()reports a pin that does not read, or a target not met, on the pinned path, with the attainable range. A build solves the pins and each scan point solves its own. A run records the authored text with its pins and the resolved one.The Python export binds the literals to
keywords, then writessolve_pin(...)andsolve_pulse(...)(public insolprop.recipe.pins) before the route’s call.Tests: a pinned pressure equals
pressure_zdwto 1e-7; a soliton-order pin gives order 4 on the launch mode; the pulse pin reads the pressure the guide’s pin settles; a scan re-solves its pins per point; two guide pins, an array element (a gradient’s), a pin also set literally, an unpinnable keyword and malformed tables are refused on their path; an unreachable target is one issue with its attainable range; the export builds the same configuration hash in a subprocess.
solprop.capabilities.route(name)andspecies(name).RouteCapabilitieslists the route’s keywords (from S9), each choice keyword’s values, and the rules that couple keywords. 42 rules, each with an id, the keywords it binds, a reason and its routes. The id is given asrule=at its raise sites (53 sites), and a lint test over the source checks each site against the registry and each rule’s use.SpeciesCapabilitiesgives the ionisation models with optmat’s own statuses (the recommended one first), the Raman status and whether it is on by default, the data ranges and the material records with their statuses. Air is refused, pointing to its components.
Deviations and decisions.
SolitonOrderholds the pulse and solves the guide; the opposite question, the pulse for an order on a given mode, is closed form and is what asoliton_orderpin uses.RDW’sshapedefaults to"sech", aspressure_rdwand the rest ofsolprop.toolsdo, not the"gauss"of §5.7’s sketch.The attainable range is computed only when there is no root, on a coarser grid (17 linear and 17 logarithmic values) with the modes rebuilt rather than kept.
A pin on one of
pulsesis exported as its solved value with the pin in a comment: the pulse object cannot be built without its energy.Pin values are written as numbers in their keywords’ units.
Rules that depend on a value, a type or the machine are not registered (a mixture at a different temperature,
precisionwithbackend="auto"and no Metal, and the like); their refusals carryparameterand norule.
Unmeasured, and a cost. An unreachable zdw pin on a capillary with the default
dispersive cladding costs about ten seconds, since the attainable range searches the
zero-dispersion wavelength at each of its samples and the glass model is slow to evaluate;
with a constant cladding index it is about a second.
Phase 4, guided half, and phase 6 report (S1 on the waveguide, S5, S13, S14, S18)#
Complete on 2026-11-02, commits 74b79db (previews), 50fb16c (drawings), 224ea3c
(launch and keywords) and a69e09e (waveguide recipes). Checked as phase 1 was. The
free-space half of phase 4 waits for SEP 40 on main.
Delivered.
Recipes on the
waveguideroute. Its metadata layers its own entries over the capillary’s fill and response entries.MarcatiliMode,RectMode,SlabModeandModeCollection.slabare registered as tables of geometry, cladding and loss: a supplied mode carries no fill, since the route rebinds every mode to its own (the configuration hash is the same whatever fill a mode was built with), andFactory.omittednames what a table may not give. Two capillary gallery scripts gain recipes now thatMarcatiliModeis registered. A recipe with a keyword itsprop_*lacks (memory_budgeton the waveguide) is exported throughSimulation.from_*.launchonprop_capillary,prop_waveguideand their constructors (solprop.driver._launch).Labels resolve by display label or index; a label without its parity is accepted where one row completes it and refused, naming both, where two do.
Each pulse is placed per mode with the square root of its normalised share and its phase. On a guide whose provider declares a change of basis the coefficients are rescaled so the flux-metric energy is the pulse’s (to 1e-13 on the circular-pair guide), recorded as a coordinate split.
configuration.modal_launchrecords each pulse’s labels, normalised shares and phases, only whenlaunchis given, so the default record is unchanged; it is compared under SEP 42, with the neutral entryNonefor older records (release 0.2.0). No schema bump, per ADR-063.launchbesidechannel,incidentorcoupling, and a per-pulse list of the wrong length, are refused with rule ids.In a recipe,
launchis a mapping or a list of mappings, addressed aslaunch["HE1,1"].share.
Keyword gaps (S14).
interpolate_planesandstep_planeson the waveguide;polarisation_taperon the capillary and the waveguide (every grid of these routes has a spectral collar);step_planesdefaults toNoneon the GNLSE, hash unchanged. The guide’s table of where the interfaces’ keywords differ gives the reasons for each kept difference, and the route-defaults table was already in the stepper page.Previews (S18, guided).
Recipe.modes(z=None)(solprop.recipe.preview) gives each mode’s label,beta,beta2, loss, effective area and zero-dispersion wavelength over the grid’s band.Recipe.preview_input()gives the launched pulse on the run’s grid without noise, read through the processing functions from a one-plane view, as an observer reads a plane. Both solve the pins first.Drawings (S13).
solprop.plotting.geometry:outline(guide)gives the cross-section as shapes in metres, each with its region (medium, wall, core, cladding, collar);draw(ax, guide, palette=...)paints them with Matplotlib patches; andthumbnail(route, representation, size)makes a bare figure for a front end’s picker. ThePaletteis explicit, withLIGHTandDARKholding the guide’s colours. Covered: capillary, antiresonant, step-index, graded-index, general modal (its provider), rectangular and slab guides, and the planar, radial, Cartesian and line grids.
Deviations and decisions.
S13 is narrowed to outlines, by the maintainer’s decision: the library draws walls, core, tubes and collar. The guide’s figure script draws SVG with
drawsvgin CSS classes, for its dark mode, so it paints the library’soutline()shapes rather than callingdraw(). It keeps its own fields, node sets and labels. Six guide figures change slightly, because the drawn wall is now one fraction of the guide’s size.No waveguide example recipe yet. None of the seven gallery scripts on the route can be written as a recipe: five build their guide from a provider, which has no table form; one passes an array to
with_input; one uses a metal cladding, which is not registered. The route is tested against the guide’s own calls instead.An explicit first-mode launch is a recorded launch.
launch={"HE1,1": 1.0}builds the default’s field to rounding, but recordsmodal_launch, so a file written withoutlaunchcontinues under it only when forced.On a slab set, a pulse’s
energyandpeak_powerare per metre of y, while the metadata’s units are J and W; the guide says so.
Phase 4, free-space half, report (S1 on the object routes, S6, S18) — and completion#
Complete on 2026-11-02, after SEP 40 reached main and was merged into the branch
(dac1743); commits 2c1bf57 and 5256885 (beam displacement and tilt) and 098a7e7
(recipes on the object routes). Checked as phase 1 was, with the free-space modules of
SEP 40 run on the merge with the native build at kernel contract 41.
Delivered.
The merge with SEP 40. The drawn outlines read
collar_fraction, and a radial grid’s collar now lies outside its radius, out toouter_radius. SEP 40’s copy of the noise resolver was dropped for the route-defaults table, which it had not changed.Beam displacement and tilt (S6).
SpatialPulsetakesdisplacement=(d_x, d_y)andtilt=(theta_x, theta_y)withcoupling.Alignment’s definitions. Both apply at the launch plane afterpropz, on aFreeGridor aLineGrid: the tilt is the screen \(\exp[\mathrm{i}k(\omega)(x\sin\theta_x + y\sin\theta_y)]\) with the launch medium’s real wave number, the displacement the exact spectral shift, and the energy is set after both. Checked: the displacement is the shiftAlignmentapplies; each frequency leaves at the one angle (the spectral centroid gives the tilt to 1e-3); the screened field isAlignment’s screen on the same beam up to the one real factor the renormalisation makes, about \(1 + \theta^2/4\); the energy after the screen is the pulse’s to 1e-12; and each refusal fires. The record gains the two fields, at zero too; the frozen 0.2.0 records continue through a neutral entry of zero.Recipes on
radial,freeandplanar. The constructor’s arguments are[build]tables under their own names (grid,space,medium,responses,initial,settings), withabsorberandabsorber_schemeundersettingsas §5.1 asks, and SEP 40’sspatial_absorberas a table orfalse. Registered: the four spatial grids (withcollar_fraction),SpatialMedium,KerrResponsewith optmat’sgamma3,EnvelopePlasmaResponse,PlasmaResponse,OptmatRate,SpatialPulse,PlanarPulse,SimulationSettings(its physical fields;[execution]keeps its four),PIController,default_specandSpatialAbsorber. Three examples (cartesian_beam,planar_hybrid,planar_compressor) build the hash of the script they mirror and export to it.Previews (S18, free space).
preview_inputgives the beam’s power, fluence and energy on the launch plane, measured in the medium there withsolprop.spatial’s functions, and equal to the run’s noise-free entrance plane;modes()covers the planar route’s slab channels.
Deviations and decisions.
S6’s record. §5.6 names “two fields of
configuration.pulse”: the beam is recorded where each route records it,configuration.initialon the free-space routes andconfiguration.pulseon a coupled run, and the neutral entry covers both.S6’s thresholds, which §4 states without numbers: a tilt is refused when \(k(\omega)|\sin\theta|\) reaches the largest transverse wave number of a tilted axis; a moved beam when its fluence at or beyond the window’s requested half-width exceeds 1e-6 of its peak; and a pulse-front delay across the beam’s 1e-6 width above half the time window.
Alignment’smodel_errorbudget does not apply, as §5.6 says.A
PlanarPulserefuses displacement and tilt. §5.6 applies them on a Cartesian grid; the planar beam is placed across the gap by its ownoffset_xand is periodic in y. A guide’s incident beam refuses them too, as §5.6 says ofprop_waveguide(incident=...).Simulation.from_radial,from_freeandfrom_planartakeabsorberandabsorber_schemethemselves since SEP 40, which §5.1 predates. A recipe still gives them undersettingsand is refused, naming that, at the top level.Slab modes take their fill on the planar route only (
Factory.fill, set by the route’s build context); the waveguide rebinds every mode to its own fill and refuses one.Pins are refused on the object routes, which have no guide or launch mode to solve a target on; their resolved recipe writes back only the drawn seed, since their values are tables, not resolvable keywords.
The export writes a tuple-typed parameter as a tuple on every route, as a script would (
wavelength_limits=(...)).No radial example. No radial gallery script can be written as a recipe: each builds its beam as an array or computes its energy from material functions, which a literal would freeze. The route is tested against the guide’s calls (#405).
Completion. Every phase is merged on the branch and reported. Recipes and the hooks sit
above translation and run on every backend; the observer, the estimate and the stop are
tested on numpy_ref, numba_cpu and the native build. The manual documents the work,
and each page it produced ends with a link to this SEP. No open question remains (Q1–Q12
are decided); the follow-ups are issues: a waveguide example
(#402), metal claddings in recipes
(#403), a slab set’s per-width units in
the metadata (#404), a radial example
(#405) and get_power on a spatial
output (#406).
Part III — History#
2026-10-31: Drafted, from the requirements of a desktop front end under design.
2026-10-31: Opus 5.5 completed the AI review (R1–R20, below). The review read a revision 321 commits behind
main; onmaina resume takes the noise seed recorded in the file when the call gives none, so R3’s refused resume no longer occurs (its points on equivalence tests and scan seeds stand), and the output schema is 28, not 27. The draft was rebased ontomain. Part I is not yet revised; the response follows the maintainer’s decisions on R2 and R10.2026-10-31: Codex completed a supplementary AI review (C1–C5, with updates to R3 and R19, below), at revision
c4cb347; Part I unchanged.2026-10-31: Opus 5.5 (drafting agent) answered R1–R20 and C1–C5 and revised Part I against
mainat03807f8(response below): a defined vocabulary with an[execution]table, exact decimal quantities, authored and resolved recipes stored without a schema change, parameter paths and pins in format 1, a lazy and failure-isolated plane observer, result capabilities (S16), a portable stop and resumability query (S17), previews (S18), and one branch and one pull request.2026-11-01: Maintainer decisions (John Travers) on Q1, Q2, Q4–Q8 (§12). Part I revised for Q5 and Q6: one
load_spectrumwith the usual three-column layout and the phase rules, andtomli-wto write TOML.2026-11-01: Maintainer decisions on Q3 (both defaults stay) and the phase sign (optics by default). All of Q1–Q8 are decided. Rebased onto
mainafter the 0.1.0 release; §3, §5.12, §5.14 and §9 brought up to date.2026-11-01: Supplementary AI review of the revised text (Opus 5.5).
2026-11-01: Opus 5.5 (drafting agent) answered G1–G25 and revised Part I (response below), with the maintainer’s leave to reopen earlier decisions: one
[build]table, factories with a declared context, one-way recipes, a resolved recipe defined by what rebuilds the run, a recipe carried by the simulation, a stop that returns, and SEP 42 as a requirement. Q9–Q12 ask for confirmation.2026-11-01: Human review (John Travers), and decisions on Q9–Q12 as recommended. Accepted by John Travers; Part I frozen.
2026-11-01: Tracking issue: #143. Brought to the standard of ADR-062 before the pull request merged: §3 lists the open issues, and Q4 names #141. §11 notes that phase 4 follows SEP 40, as the maintainer ordered.
2026-11-01: In progress. Phases 1–3 started on
feature/sep-0041-recipes(John Travers, with Opus 5.5), ahead of SEP 42’s phase 1 (Part II).2026-11-02: Final. Every phase reported in Part II; follow-ups opened as #402–#406; the SEP moved to the archive (John Travers, with Opus 5.5).
AI review, 2026-10-31#
Reviewer: Opus 5.5 (AI). Read the whole of Part I at revision 02d09ab against SEP 0’s
acceptance criteria and the code on that revision, with probes in the project
environment (signatures, small builds, one 1 mm GNLSE run on numpy_ref, no test
suite). Recommendation: revise before acceptance. The purpose and the split of work
between solprop and a front end are sound, and the API changes are additive as claimed.
Three findings are blockers: the default configuration cannot be resumed from a file
(R3), the format’s vocabulary is not one set of keywords (R4), and the phase plan
contradicts SEP 0 (R10). Two findings concern the first public release (R1, R2).
One trivial edit was made to Part I: in section 5.1 the inline GaussianPulse table
used lambda0, which GaussianPulse does not accept; it now uses wavelength, its
keyword. Nothing else in Part I, the header or the history list was changed.
Findings#
R1 — major: the schema bump ends resumability of every earlier file (sections 9, 11, 5.5, 5.6, 5.14). The schema version is inside the resume key (
src/solprop/output/schema.py:2896), and the guide says so: “only a file written at the current output schema … can be resumed” (docs/guide/results/resuming.md:47). So phase 2’s bump from 27 to 28 makes every file written by the first public release unresumable by the version that adds S2, by any route. Section 9’s “a file written before has no recipe and is resumed from its script” is therefore false. The same applies to S5, S6 and S14 if they add recorded fields: “a change that adds a field to a recorded dataclass moves the configuration hash and bumps the output schema” (docs/guide/results/output.md:336), andSpatialPulseis a recorded dataclass (fieldstemporal, waist, order, propz, jones). Recommendation: therecipedataset is optional and outside the hash, and no reader check refusing a newer schema was found, so store it without a bump and test for its presence. Alternatively, reserve it before the release is tagged. Require S5, S6 and S14 to record new keywords only when they differ from the default, asconfiguration["transverse_measure"]already does, so that their phases do not move the hash. Correct section 9 either way.R2 — major: the release, not recipe format 1, is the deadline for S14’s open rows (sections 5.14, 9, Q3). Q3 recommends settling the
atoldefaults (1e-14 onprop_gnlse, 1e-10 on the gas routes) and the GNLSEramandefault “before recipe format 1 is published”. Once solprop is public, changing either is a change of default for every script, which section 9 itself rules out. Section 9 calls renaming “not recommended”. The release also fixes the vocabulary splits that recipes inherit at two levels: routes takelambda0andpulseshape, while pulse objects andsolprop.toolstakewavelengthandshape(GaussianPulse(*, wavelength, …),soliton_params(…, wavelength, shape)). Recommendation: decide Q3, and whether these splits are ever to be unified, before the release is tagged. If they are kept, list them in S14’s table as deliberate. Nothing else in this SEP needs to land first: every proposed name is new.R3 — blocker: with default noise, a rebuilt simulation never matches its file (sections 4, 5.1, 5.2, 7).
noise="default"withseed=Nonedraws a fresh seed at each build. TwoSimulation.from_capillarycalls with identical arguments differ innoise.seedand the whole noise spectrum (RunMetadata.differences). Runningprop_gnlsefor 1 mm and calling it again withresume=Trueis refused: “these fields changed: noise.seed, noise.spectrum…”. “Only what was set is written” therefore makessolprop resume run.h5fail for any default run, and the recipe equivalence test of section 7 fails unless a seed is pinned.Recipe.scan()has the same issue: a scan builder must passpoint.seed, or the point is rejected (docs/guide/workflows/scans.md:255). Recommendation: record the resolved seed with the stored recipe, or have resume take it from the file’s noise record. If the change that reuses a stored seed on resume lands first, cite it; on this revision it has not. Define recipe equivalence with the seed fixed, and makeRecipe.scan()’s builder passpoint.seed.R4 — blocker: “the route’s keywords” are three different sets (sections 5.1, 5.2, 5.14, 6).
prop_*takesoutput,progress,progress_period,resume,snapshots,boundary_checkand, on the capillary,tdse_archive. None of these is aSimulation.from_*keyword;boundary_checkis applied afterwards toSimulation.settings(src/solprop/driver/interface.py:387), and the rest go toSimulation.run.from_waveguidetakesmemory_budget, which noprop_*has. Some settings a front end needs are on no keyword surface at all:fields,field_planesandresume_history. The guide sets them by replacingsimulation.settings(docs/guide/setup/running.md:445).Recipe.build()returns aSimulation, andto_python()writes aprop_*call, so the format must say which set it uses. Recommendation: define the vocabulary as thefrom_*keywords plus a named list of execution settings (boundary_check,memory_budget,fields,field_planes,backend). Refuseoutput,progress,resumeandsnapshotsin a recipe, since the command line supplies them. Extend S9’s sync test to all three surfaces, and add thememory_budgetasymmetry to S14.R5 — major: the quantity rule is not exact, and one unit per keyword is not enough (sections 4, 5.1, 5.9, 7). Scaling by a factor table in binary floating point changes values.
7.5 * 1e-15is7.500000000000001e-15,800 * 1e-9is8.000000000000001e-07and275 * 1e-6is0.00027499999999999996, each 1 ulp from the literal a script writes. Records serialise floats exactly, so section 1’s round trips (equalconfiguration_hash) fail for the SEP’s own example. Several keywords also mix units inside one value:phasesis[rad, s, s², s³, …](src/solprop/driver/interface.py:531),betasis in sⁿ/m by order, and the(z, p)form ofpressurepairs metres with bar (interface.py:870). Recommendation: convert in decimal, by exponent shift of the parsed mantissa, then round once to the nearest double; the three examples then give the literal. Check dimensions, not only scale, so that"125 um"forpressureis refused. Let S9 give a unit per element or per form for these keywords. Writing8e-07back as"800 nm"needs the same decimal rule.R6 — major: format 1 must say how to write
None, and whatfrom_callrefuses (section 5.1). TOML has no null. YetNoneis a deliberate non-default value for several keywords:noise=Noneturns noise off against the default"default"(interface.py:131), andabsorber=Noneandabsorber=Falsemean different things. Callables are accepted bybeta,lossandMarcatiliMode(loss=…); the format cannot hold them. Also,ZProfilevalues built by class methods (PressureGradient.two_point) are not described by a constructor’s keywords. Recommendation: fix a spelling forNonein format 1, for example the string"none"where a keyword acceptsNone, and record it in the metadata. State thatfrom_callrefuses callables and arbitrary arrays, naming the keyword. Test every registered type in both directions (table → object → table), not only its constructor.R7 — major: the recorded recipe can disagree with the run it is stored beside (section 5.2). The recipe is outside the hash, so nothing checks it. A simulation built from a recipe can still change before it runs: the
settingssetter (src/solprop/driver/simulation.py:1755),with_input(which takes an array) andwith_mixture. The file would then hold a recipe that does not rebuild its run. “Only what was set is written” also means a file reopened under a later solprop shows that version’s defaults, not the run’s. Recommendation: store with the recipe theconfiguration_hashit built. At run start, drop or refuse the recipe when the simulation’s hash differs, and on resume compare the two. Store a resolved recipe (every keyword, the seed included) beside the as-written one, so that a front end can show the setup that ran.R8 — major: the plane observer’s device and cost statements do not hold in general (sections 5.3, 6). “On device backends the plane has already been copied to the host” is false for field-free planes. Under
fields="none"or"planes", “a field-free plane asks the backend for no field at all … so nothing crosses to the host” (src/solprop/driver/run.py:739), andrun_scandefaults tofields="none". IfNullReporterimplementsobserve, everyprogress="none"run satisfiesObservingReporter; the protocols areruntime_checkable, so the check is by name (src/solprop/log.py:340). The driver could then build aPlaneViewat every plane of every quiet run. For the same reason, any existing reporter with an attribute namedobservewould start receiving calls;Backend.observe(*, field=…)shows the name is already in use. The time-domain and per-mode views need transforms that someone pays for. Recommendation: say whatPlaneViewoffers on a field-free plane: no field, or a fetch whose transfer is stated. MakePlaneViewlazy, and do not giveNullReporterthe method. Choose a specific name, such asobserve_plane. Test neutrality onnumpy_ref,numba_cpuand the native build, with field-free planes included.R9 — major: S10’s premise and S8’s completeness test do not match the code (sections 3, 5.8, 5.10, 7). Builders already raise
SolpropErrorsubclasses: across the package there are 249raise DriverError, 574TranslationErrorand 409ModeError, and only 17 bareValueError/TypeError, none in the interface builders. So the reclassification in section 5.10 is nearly empty. The real gap is the other way:DriverErroris aRuntimeError(src/solprop/exceptions.py:106), soexcept ValueErrordoes not catch a refused keyword. “Every refusal is listed” cannot be tested as written.driver/simulation.pyalone has 151 refusals, andtranslate/has 533; most depend on values (grid coverage, node budgets, material validity), not on keyword combinations.check()stops before translation, which holds those 533 sites and costs 0.13 s warm against 0.02 s for the build in a probe of section 5.1’s capillary. Recommendation: give rule ids only to the combination refusals of the interface layer (route × keyword × species). Enforce them with a lint-style test that everyparameter=refusal names a rule, and leave value refusals tocheck(). Letcheck()translate, stopping before backend construction. Collect every issue only at the metadata level (type, unit, range, unknown key), then report the builder’s first refusal.R10 — blocker: the phase plan contradicts SEP 0 (section 11). “Each phase merges on its own with a green gate” and “a front end can start on phase 1 alone” conflict with SEP 0 Implementation. Every phase is made on one branch, and the SEP reaches
mainin one pull request when its last commit setsFinal. Under that rule the front end gets nothing until all six phases, including drawings and retrieval formats, are done. Recommendation: either split the proposal, with S1, S2, S9, S10 and S15 in this SEP and the front-end hooks (S3–S8, S11–S14) in a second, or record a justified exception to SEP 0. Phase 1 also coversprop_waveguide, whosemodesandproviderare objects. That brings mode and guide types into phase 1’s registry, not phase 4’s.R11 — minor: S5 overlaps keywords that exist and names modes in a form that does not exist (section 5.5).
prop_waveguidealready haschannel(“by index or display label”,interface.py:1376) andincidentwithcoupling. Mode labels areChannelLabels that display asHE1,1:even, not"HE11", and the parity matters when both polarisations are present. No keyword of these routes takes a field array; the array path isSimulation.with_input. A per-runlaunchcannot express a two-colour input with the pump and seed in different modes. Recommendation: resolve labels aschanneldoes, state howlaunchrelates tochannel,incidentandwith_input, and decide whetherlaunchis per pulse.R12 — minor: S6 introduces physics without theory, and a second name for an existing quantity (sections 4, 5.6).
coupling.Alignmentcalls the offsetdisplacement. Its tilt is a phase screen with the medium’s wave number, certified against a requiredmodel_error(src/solprop/coupling/elements.py:1612). For a broadband beam, a tilt defined with \(k(\omega)\) is a pure angle, while one defined with \(k_0\) adds angular dispersion. Section 4’s “no new physics” does not settle which. Recommendation: define the tilt in section 4, reusedisplacement(or rename both consistently before either is published), and say whether the screen’s budget applies. See R1 for the record.R13 — minor: the general design solver needs more than a mode builder (sections 3, 5.7, 7).
pressure_rdwalready takes alatticeand builds aVincettiMode(src/solprop/tools.py:2661), so section 3’s “written for the Marcatili capillary” is too narrow. The tools search rather than bracket once: the zero-dispersion search samples \(\beta_2\) and discards sign changes through a pole, because a VUV resonance flips its sign twice, and the modes’ ownzdwmisses such pairs (docs/guide/workflows/design-tools.md:98-111);pressure_rdwsamples 129 logarithmic and 129 linear pressures. A generic root finder over a bracket would reintroduce the failure the tools avoid, and the parameter-to-target map need not be monotonic.solve_parametershould reuse these searches and report every root it finds. The("rdw", (pump, rdw))target omitspressure_rdw’speak_powerandnonlinear_phase, so it can reproducepressure_rdwonly for linear phase matching. Amodekeyword beside the required positionalradiusis awkward. Recommendation: carry the nonlinear phase in the target, return all roots, and make themodeform a separate function or makeradiusoptional whenmodeis given.R14 — minor: metadata needs effective defaults and constructor tables (section 5.9). Many defaults are
Noneresolved by rule:rtol(per route),plasmaandthg(not envelope),step_planes(0, or 1 with TDSE),raman(by species) andpressure(one bar). A front end must show the value that will be used, which the signature does not give. Object tables (GaussianPulse, modes, profiles) need units for their own keywords, but S9 covers only interface functions. Recommendation: add a “resolved by” rule or resolver per keyword, and extend S9 to every registered type.R15 — minor: section 3 is wrong about scan progress (sections 3, 5.4).
run_scanalready hasprogress="auto"with an outer bar or one log line per finished point (src/solprop/driver/_scan_run.py:760); only the inner runs useprogress="none". A pool knows when it dispatches a point, but per-point events from a worker need new messages on the worker pipe. Recommendation: correct section 3, state how aScanReportercombines with the literal choices, and say what the pool’s forwarding sends.R16 — minor: the cost estimate as described would include start-up and could allocate what it is meant to admit (section 5.11). Timing right-hand sides needs a constructed backend, which allocates the persistent memory the estimate reports. On a cold process, numba compilation and FFTW planning exceed “about a second”. Calls per step differ by integrator (one for the exponential multistep, six or seven for Runge–Kutta). Recommendation: admit memory first, exclude warm-up from the timing, and report cost per right-hand side with the integrator’s calls per step.
R17 — minor: loading other people’s files needs stated limits (sections 5.1, 5.2, 7).
solprop resumeon a received HDF5 file executes that file’s recipe. Embedded data files must be read into memory or written under generated names, never at their stored names, which could hold../paths. The recipe’s execution keys must not choose output, archive or snapshot paths (R4). Node and memory budgets bound a hostile grid. “Outside the recipe’s directory” must resolve symbolic links. Parsing an untrusted HDF5 file is itself an exposure of the HDF5 library. Recommendation: add these to section 5.2 and to the safety tests, and say in the guide that resuming a received file runs its setup.R18 — minor: a front end needs a few hooks the SEP does not list (sections 2, 5). Exploring a guide before a run needs the modes a recipe would build, without building the run (
Recipe.modes()or similar). A preview of the input needs the pulse and beam on the grid. Warnings should carryparameteras errors will (MaterialRangeWarning,BoundaryWarning, the provisional-Raman warning). The boundary monitor’s verdict should reach a reporter, not only the log and the file. Cross-field rules (energy or peak power; pulse inside the window) should be machine-evaluable if S8 is to serve instant validation. Recommendation: add these to S8–S10, or defer each explicitly to a named phase.R19 — minor: verification tolerances and backends (section 7). “Matches … to rounding” should name the bound and where it is set (the reproducibility page). The loaders need a stated tolerance on energy and centre wavelength. SEP 0 Completion requires the observer and the estimate to run on
numpy_ref,numba_cpuand the native build, with parity. Round-trip tests need a pinned seed (R3) and exact quantity conversion (R5). Recommendation: add these to section 7.R20 — minor: other statements about current code (section 3).
prop_planartakes nomedium, and all three object routes takegridfirst andframe.SpatialPulse’s signature starts withtemporal. Not every error carries only a message:ResponseBudgetErrorcarriesz,fieldandcalls. S15’s sidecar is already documented, under snapshots (docs/guide/results/output.md:475), so S15 moves the text rather than writing it. The guide pages section 8 will edit state stale schema numbers: 22 inresuming.md:47and 25 inoutput.md:308, againstSCHEMA_VERSION = 27. Recommendation: correct section 3. The stale numbers are outside this SEP but are worth fixing before the release.
Verdict on “every change is additive”#
For the Python API the claim holds. Every name is new, and new keywords default to
today’s behaviour. Exception reclassification by multiple inheritance keeps existing
except clauses. step_planes=None on prop_gnlse widens an int annotation without
changing behaviour. There are three qualifications. Files are not compatible across
the change: the schema bump and any newly recorded fields end resume of earlier files
(R1). observe on NullReporter changes what the driver does for existing reporters
that already have such an attribute (R8). And the defaults and names S14 leaves open
become fixed when solprop is first published, not when recipe format 1 is (R2).
Nothing must land before the release for API reasons. Q3, the vocabulary splits and the
schema policy for the recipe dataset should be decided before it.
Evidence and limits. Probes on revision 02d09ab:
inspect.signatureof the six routes, the threefrom_*,Simulation.run,run_scan,SpatialPulse,DataPulse,GaussianPulseand the design tools.Repeated
from_capillarybuilds compared withRunMetadata.differences.A 1 mm
prop_gnlserun onnumpy_refand its refused resume. The native backend was not used: the shared environment’s extension did not match this revision.Build and translate timings of the section 5.1 capillary, warm.
Counts of
raisesites by class.The three float products in Python.
No test suite, propagation qualification or GPU run was performed. Front-end needs were judged against the requirements stated in Part I, section 2.
Supplementary AI review, 2026-10-31#
Reviewer: Codex (AI). Reviewed Part I, the existing AI review and relevant source and
documentation at revision c4cb347, considering the requirements of a desktop front
end. Recommendation: retain the architecture, but revise the outstanding contracts
before acceptance. This supplement adds findings or updates earlier ones; it does
not repeat R1–R20 or change Part I, the proposal status or the earlier review.
Additional findings#
C1 — major: expose result capabilities as well as setup capabilities (sections 2, 5.3 and 5.8; additional to R18). A front end needs to know which quantities can be reconstructed from a particular file, not only which options a route accepts. The processing guide warns that
get_powerandget_spectral_densityaccept spatial files but do not return physical powers or energies there. Compression refuses spatial and general modal outputs. General modal reconstruction requires the matching guide and reference tables, while the file may contain only their identities. Field retention and recorded statistics further limit each plane’s available views. Recommendation: expose these requirements and refusals through a result-capability query, extending the existing output capability machinery where appropriate. Distinguish physical flux, coordinate quantities and their units. Do not promise thatPlaneViewalone makes the same physical operations available on every basis.C2 — major: define pins and nested parameter paths before format 1 is frozen (sections 5.1, 5.7, 5.9 and 5.10; additional to R13 and R14). A setup that pins pressure to a target ZDW needs both the constraint and its resolved value. The proposed keyword tables do not yet describe this intent, when to re-solve it, the chosen root and search domain, or failure when a target becomes unreachable. A scan of a second pulse’s energy or a profile knot also needs an address within an object, beyond a top-level route keyword. Recommendation: define canonical nested parameter paths shared by editing, diagnostics, scans and constraints. Specify constraint dependencies, cycle refusal, resolution order and whether each scan point re-solves its pins. Retain the authored intent beside the resolved run definition, and define equivalent headless and Python-export behaviour. If pins are deferred, state that limitation explicitly rather than leaving scientific setup state to individual front ends.
C3 — major: mode energy fractions are not generally additive (section 5.5; additional to R11).
prop_waveguidealso supports general modal bases whose physical energy uses a flux metric with cross terms. A set of coordinate fractions normalized to one does not establish the advertised physical energy allocation; relative phases can affect the total. The existing general modal processing contract distinguishes coordinate quantities from physical flux. Recommendation: explicitly restrictlaunchfractions to bases where this interpretation holds, or define a coordinate-amplitude specification normalized using the actual flux metric. Resolve this physical meaning alongside the labels and per-pulse scope already raised in R11.C4 — major: front ends need a portable stop contract (sections 2, 5.2 and 5.3; additional to R18). Sending
os.kill(pid, signal.SIGINT)does not gracefully interrupt a Windows worker: Python documents unconditional termination for values other than its special Windows console-control events (os.kill). A front end cannot infer resumability from a process exit or fromn_savedalone. Some failures have no checkpoint, and interpolated checkpoints need an explicit policy (resuming). Recommendation: specify either a supported portable cancellation hook respecting commit boundaries or the tested platform mechanisms a front end must use. Expose enough recovery information to explain whether resume is available. Keep front-end execution states distinct fromRunOutcome.status, which has nostoppedmember. A changed grid, including a wider time window, requires a new run rather than ordinary resume.C5 — major: observer neutrality needs a failure and workload policy (section 5.3; additional to R8). Read-only plane views prevent ordinary field mutation, but do not prevent a slow observer from delaying the march or a failed transport from raising through the run loop. The current
reporter.planecall is inside the driver’s exception-recovery boundary. Recommendation: define the observer exception policy and the handling of a disconnected client. Give front ends a documented way to bound observation work and coalesce replaceable display updates without blocking propagation on client rendering. Keep reliable terminal/error events distinct from replaceable previews. These transport policies can belong to the front end, but the solprop hook must state the guarantees it does and does not provide. Test observer failures and disconnection as well as successful copies.
Updates to earlier findings#
R3, unseeded resume: the resume blocker is addressed at this revision.
Simulation._adopt_recorded_seedinsrc/solprop/driver/simulation.pyreuses a file’s seed when the caller supplied none; the behaviour is now documented in resuming. The earlier probe remains evidence about the revision it tested. Independent builds used in recipe-equivalence tests still need controlled seeds; this does not remove R7’s requirement for resolved provenance or settle every scan-builder detail.R19, comparison policy: change the comparison class, not just its tolerance. Section 7 asks for resumed versus uninterrupted agreement to rounding and bitwise agreement between independent runs with and without an observer. The current testing policy puts ordinary resumed versus uninterrupted runs in class T, with an accuracy envelope against a tighter or stored converged reference. A rounding comparison requires matched discrete methods and histories; independent propagation runs do not require bitwise agreement. Exact comparison remains appropriate for an observer’s copy against the same plane read from its file, and for configuration identity metadata.
Evidence and limits. Read the designs, the cited manual pages, the driver and processing implementations, and the existing interruption/output tests. Checked Windows signal behaviour against Python’s documentation. No simulations or test suite were run as part of this review; the findings about runtime behaviour are source-level assessments, not new qualification results.
Response to review — 2026-10-31 (Opus 5.5, drafting agent)#
Both reviews were read against main at 03807f8, after the release preparation merged.
Two facts of that revision change the ground the first review stood on: the output schema
is now 1, and a file of any other schema is refused outright (ADR-058); and a resume
takes the seed recorded in the file. Every point is accepted in substance. The principal
changes to Part I are:
the vocabulary is defined:
from_*keywords, an[execution]table, and refused keys (R4);quantities convert exactly in decimal, with dimensions checked (R5);
the recipe is stored as written and as resolved, with the hash it built, and needs no schema change (R1, R7);
parameter paths and pins are part of format 1 (C2);
the observer is lazy, specifically named, and cannot fail the run (R8, C5);
three new items: result capabilities (S16), a portable stop with a resumability query (S17), and previews (S18) (C1, C4, R18);
one branch and one pull request (R10).
The maintainer’s decisions on the points remain to be recorded beside them.
R1 — accepted, and stronger on the released code. Since ADR-058 a file of another output schema is not read at all, so a bump would cost more than resumability.
Changes:
§5.2: the recipe is an optional group outside the hash, and needs no schema change. A reader that does not know the group ignores it. §7 tests a schema-1 file with and without the group.
§5.14: the new keywords map onto settings already recorded.
§5.5, §5.6: S5 and S6 record through the input and
Alignmentrecords where they can. Otherwise their record changes are confined to phase 4 as one bump, a listed break (§9, new Q8).§1 and §9 are corrected: the API is additive; files may see one break, and it is named.
Not taken: recording new fields only when they differ from the default. The output guide’s rule that every field is recorded, at its default too, keeps record changes deliberate, and an exception for this SEP would weaken it.
R2 — done in part, the rest is Q3. The vocabulary split is gone. PR #64 renamed the route keywords
lambda0andpulseshapetowavelengthandshape, without aliases, before the 0.1.0 tag. The recipe example and §3 now use them.Changes:
Q3 now asks for a decision on the
atoland GNLSEramandefaults before this SEP merges, which is when format 1 is published. Under ADR-058 a 0.x minor release may change a default if the changelog lists it, so the deadline is that merge rather than the release tag.S14 marks both rows as Q3.
R3 — resolved on
main; its remaining points accepted.Simulation._adopt_recorded_seedtakes the file’s seed when the call gives none, sosolprop resumeof a default run is not refused.Changes:
§4 defines equivalence with the seed fixed, and §7’s equivalence tests fix it.
§5.2’s resolved recipe records the seed.
§5.1’s
Recipe.scan()builder passespoint.seed.
R4 — accepted.
Changes:
§5.1 defines the vocabulary: the build keywords are those of
Simulation.from_*.The execution settings
boundary_check,memory_budget,fields,field_planesandresume_historygo in an[execution]table.output,progress,progress_period,resume,snapshotsandtdse_archiveare refused, by name.to_python()writes aprop_*call when every key is on its surface, and otherwisefrom_*, the settings andrun.S9’s sync test covers all three surfaces.
S14 lists
memory_budget.§3 states the three surfaces.
R5 — accepted. The three products were reproduced.
Changes: §4 states the decimal rule:
parse the mantissa, shift by the prefix, round once to binary64;
check the dimension, so
"125 um"forpressureis refused;write back with the prefix in which the shortest representation of the double is exact, so the round trip is the identity.
S9 has a unit per element or per form for
phases,betasand the(z, p)pressure, and §7 tests the example’s values.
R6 — accepted.
Changes:
§5.1 writes
Noneas"none", and the metadata records which keywords accept it. A sync test checks that no keyword accepts bothNoneand the string"none"as different values. Without that test the spelling would be ambiguous for choices such asfields = "none".§5.1 names what a recipe cannot hold (callables, arrays other than data files), and
from_callrefuses those by keyword.Registry entries may be class methods (
PressureGradient.two_point).§7 tests every registered type in both directions.
R7 — accepted.
Changes: §5.2 stores, beside the authored recipe, a resolved one: every keyword, the defaults, the pins and the seed, with the hash the recipe built.
If the simulation was changed after its build, its hash differs. The driver then writes no recipe and warns. This is chosen over refusing the run, because changing a built simulation is a legitimate Python workflow.
solprop resumerebuilds from the resolved recipe, so a later solprop’s defaults do not move the run, and it checks the stored hash.Recipe.from_output(path, resolved=...)gives either recipe.
R8 — accepted.
Changes: §5.3 renames the protocol
PlaneObserverand the callobserve_plane.NullReporterdoes not implement it, so a quiet run builds no view.PlaneViewis lazy. On a field-free plane on a device, asking for the field fetches one plane, and that transfer is counted in the resource summary.§6 drops the claim that the plane is always on the host.
§7 tests neutrality on
numpy_ref,numba_cpuand the native build, field-free planes included.
R9 — accepted. The counts were taken as given; one fact was checked:
DriverError(SolpropError, RuntimeError).Changes:
§5.10 drops the reclassification, and no exception changes class.
parameter(a path) andruleare added toSolpropErrorand to the value-related warnings.§5.8 gives rule ids only to the interface layer’s combination refusals, enforced by a lint-style test. Value refusals are left to
check().check()collects every metadata-level issue, then builds and translates, stopping before the backend is constructed. It reports the first refusal.The 0.1 s translation cost is stated, with the advice to call
check()on a pause in typing.§10 records why there is no rule language for cross-field validation.
R10 — accepted: one branch, one pull request. This follows SEP 0.
Changes:
§11 makes the phases commits on one branch, with
Finalin the last.A front end in development builds against the branch, and the phases are ordered so that its needs come first: the recipe, then resume and stop, then watching runs.
prop_waveguidemoves to phase 4 with the other object-carrying routes, so phase 1’s registry holds only the gnlse and capillary objects.§10 records the split into two SEPs as an alternative. It was not taken, because the hooks name recipe paths, and format 1 should freeze once, with the pins and paths it must describe.
R11 — accepted, with C3.
Changes: §5.5:
labels resolve as
channelresolves them, with the parity ambiguity refused;launchtakes one mapping, or one mapping per pulse, so a pump and a seed can go into different modes;launchgeneraliseschannel, so giving both is refused, and it is exclusive withincident/coupling;with_inputremains the array path.
The example uses the real display form
HE1,1.
R12 — accepted.
Changes: §4 defines the tilt as
Alignmentdoes, with \(k(\omega)\). That makes it a pure angle with no angular dispersion. Its validity is a band and edge condition, checked at build.§5.6 renames the offset
displacementto matchAlignment.It also states that
Alignment’smodel_errorbudget, which certifies a screen against a guide’s modes, does not apply to a free-space launch.The record follows R1.
R13 — accepted.
Changes: §5.7’s
solve_parameterreuses the sampled searches and the pole filter, and returns every root, or the attainable range when there is none.The
rdwtarget takespeak_power.The capillary functions gain no
modekeyword:solve_parameteris the general form.§3 now says that
pressure_rdwtakes alatticeand builds aVincettiMode.§7 tests the two roots of a resonance.
R14 — accepted.
Changes: §5.9 adds a resolver for each default that is
Noneresolved by a rule. It returns the value and the rule’s text.S9 now covers every registered recipe type.
Q7 now recommends including the display unit.
R15 — accepted.
Changes:
§3 now states what
run_scanalready reports.§5.4 says that a
ScanReporteris accepted beside the literals.It says a pool reports dispatch and collection itself, and that per-point progress needs a new pipe message carrying
zand thePlaneSummary, never a field.
R16 — accepted.
Changes: §5.11 admits memory before anything is allocated. It excludes the warm-up from the timing and reports it separately, and it reports cost per right-hand side with the integrator’s calls per step.
R17 — accepted.
Changes: §5.2 adds the received-file limits: the same checks as a loaded recipe, embedded files read into memory, never written at their stored names, budgets, and the HDF5 exposure.
§5.1 resolves symbolic links.
§7 and §8 carry the tests and the warning.
R18 — accepted.
Changes:
S18 (§5.18) adds
Recipe.modes()andRecipe.preview_input().§5.10 puts
parameteron warnings.§5.3 adds
observe_boundaryfor the monitor’s verdict.Cross-field rules are machine-evaluated by
check(), not by S8 (§10).
R19 — accepted, with the Codex update.
Changes: §7 names the class of each comparison:
equivalence, export, round trips and quantities are class P;
resume against uninterrupted is class T, an accuracy envelope set per case;
the observer’s copy against its file is class P;
with against without an observer is class R, on a fixed schedule.
The loaders have stated bounds, and the observer and estimate run on the three backends.
R20 — accepted.
Changes: §3 is rewritten:
the object routes take a grid first and
frame, andprop_planartakes nomedium;SpatialPulsestarts withtemporal;ResponseBudgetErrorcarries data;the sidecar is already documented, so S15 moves the text;
the schema numbers are the released ones.
The guide’s stale schema numbers were fixed by the release preparation.
C1 — accepted.
Changes: S16 (§5.16) adds
solprop.capabilities.output. It reports, per derived quantity, physical flux or coordinate quantity with units; field retention by plane; and the available and refused operations, with reasons. It extends the existing output capability machinery.PlaneViewcarries the same description, and §5.16 says it does not make the physical operations available on every basis.
C2 — accepted, in format 1.
Changes: §5.1 defines canonical paths (
pulses[1].energy). They are shared byset/get, issues, scans and pins.A
[pins]table states each pin’s target and search range, and how a root is chosen.Pins resolve after the literals, in dependency order; cycles are refused, and an unreachable target is reported with the attainable range.
Each scan point re-solves its pins.
The authored pin is kept in the authored recipe and the resolved value in the resolved one, and the Python export writes the solver call.
Pins are implemented in phase 5 with S7, but they are part of format 1, which is published only at the merge.
C3 — accepted.
Changes: §4 defines launch normalisation. On a flux-orthogonal basis, shares are energy fractions. On a general modal basis, they are relative amplitudes, scaled so that the flux-metric energy is the pulse’s, and the split is reported as a coordinate one.
§5.5 applies this, and §7 tests both.
C4 — accepted.
Changes: S17 (§5.17) adds a
stop=flag torunandrun_scan. The flag is polled where SIGTERM takes effect, and raisesRunTerminatedthrough the same path, so the state is written.solprop runmaps signals, including Windows’ break event, onto the flag.resumability(path)says whether and how a file continues, and why not.RunOutcome.statusis unchanged, and a front end’s states stay its own.A changed grid, including a wider window, is a new run (§5.17, Q4).
C5 — accepted.
Changes: §5.3 states the policy. An observer exception is logged once, warned about, and the observer detached; the run continues, and terminal calls are still delivered.
RunTerminatedandKeyboardInterruptpass through.Observer time is reported in the resource summary.
The guide gives the pattern: reduce, a bounded queue that replaces undelivered previews, and a reliable channel for terminal events.
Transport and disconnection are the front end’s.
§7 tests a failing observer, and a stop raised from one.
From the review of the front end’s design (finding G9, not a point on this SEP): a
front end with a default of its own breaks the principle of §2. §5.1 now ships the guide’s
example recipes in the package (Recipe.example), so a front end’s starting points are
solprop’s.
Supplementary AI review — 2026-11-01 (Opus 5.5): the revised text#
Reviewer: Opus 5.5 (AI), reading the revised Part I independently of the drafting session,
against the code at revision 4059dc6, SEP 42’s §4 and §5, and the front-end design’s use
of the hooks. Recommendation: revise §5.1, §5.2 and §5.7 before acceptance. The split
of work and the choice of recipe keys equal to keywords hold up, and the quantity rule is
right. Two findings are blockers: the resolved recipe as defined does not rebuild its run
(G1), and the round trips rest on an object-to-table step that the classes cannot give
(G2). G3–G14 are significant and G15–G25 are minor. The G numbers are this review’s own and
have nothing to do with the finding G9 of the front-end design’s review, mentioned above.
Probes are listed at the end; every number below was measured on numba_cpu or
numpy_ref unless it says that it was read.
Findings#
G1 — blocker: the resolved recipe as defined does not rebuild its run (§5.2, §5.9). §5.2 stores “every build keyword and execution setting at the value used”, and
solprop resumerebuilds from it. Rebuilding with values read back from a default build moved the hash in two of about twenty keywords, and others cannot be written at all:dz_init(the value insettings.dz_init): the hash moves atstepper.limits.auto_init, because a default build uses the start-up rule and an explicit first step switches it off. So every default capillary or GNLSE run would restart differently. Under today’s gate it would be refused.raman_fractiononfrom_gnlse(raman=True): the default resolves to the silica bundle’s value (0.2188 here). Writing that recorded number back leaves the Kerr coefficient alone but moves the hash atmaterials.policies.gnlse_raman.origin, because the record says where the fraction came from. SEP 42’s table has no row for that path, so it is compared there and the resume is refused under both gates.The default
absorberis anAbsorberholding four arrays, built byboundaries.absorber(grid). The defaultcladdingis an optmat glass object. Neither has a table form.grid,wavelength_limitsandtrangeare alternatives, as arefwhmandwidth, andn_saveandsave_planes, so “every keyword” cannot be meant literally.settings.backendholds the resolved name (driver/simulation.py:927). The hash leaves the backend out so that a run can move between machines; a resolvedbackendwould take that away.
These did keep the hash:
rtol,atol,dz_max,integrator,stats,temperature,plasma,thg,raman,step_planes,save_planes,loss,model,pressure=Noneagainst1.0.Recommendation: define the resolved recipe positively: the authored keys, the pins’ solved values, the seed, and a declared list of keywords whose S9 resolver is tested to give the same hash when written explicitly. The rest stay “left to the build”, and the resume gate is what catches a moved default. Say that “a later solprop’s defaults do not move the run” holds for the listed keywords only. Resolve registered objects recursively, since a pulse’s or stepper’s own defaults can move too. Keep
backendas requested. Add a sync test: each resolver’s value, written back, keeps the hash.G2 — blocker: there is no object-to-table step, and the registry rule cannot express the manual’s own setups (§1, §5.1, §7).
Recipe.from_calland two §7 tests (“every registered type goes table → object → table and back unchanged”, and equivalence throughfrom_call“for every example”) need a table from a built object.Objects do not keep what made them.
PressureGradient.two_point, §5.1’s own example, holdspositions,pressures,floorand_bore(profiles.py:1797), and it comes back as aPressureGradient, not as the class method.TubeLattice.from_gapholdstube_radiusand no gap.EnvelopeGrid.from_limits, which every object route needs, holdst,omega,t_fine,omega_fineand collars, not its limits.The manual’s radial setup uses module-level functions, with
absorber=absorber(grid)(docs/guide/free-space/beams.md:112) inside the settings. An entry is “a constructor or a named class method”, soboundaries.absorber(boundaries.py:533) is not admitted,Absorber’s constructor wants four arrays, and no table can refer to the siblinggrid.Mixture.of(*components)has no keywords to write.load_spectrum, §5.12’s route to aDataPulse, is a function as well.“Every example” cannot hold as written. The gallery scripts call
with_inputin at least four places, and examples passprogress="none"oroutput=, which §5.1 refuses.from_callhas to drop those keys, not refuse them.
Recommendation: make an entry a named factory (constructor, class method or function) with declared keywords and a declared build context it receives, such as
grid, and define that context in format 1. Letfrom_callinvert only the types shown to be lossless, and refuse the others by keyword, or have a factory attach the table it was built from. Rewrite the §7 tests as table → object → hash and table → text → table, and list the examples excluded, with why. Decide this now. Phase 4 would otherwise find it after phases 1–3 have been built on a registry that cannot grow this way.G3 — significant: format 1 has no spelling for values the keywords take (§5.1). I classified all 41 keywords of
from_gnlseand all 61 offrom_capillaryfrom their annotations. Apart from the callables refused by design and the G2 objects, three gaps remain:Complex numbers.
claddingisGlassIndex | complex | None, andSpatialPulse.jonesis a tuple of complex numbers, recorded as((1+0j),)by default.tomllibrejects1.45+0j, and §5.1 defines no table or string for it.The seed. A drawn
noise.seedis about 128 bits (127 in the draw measured), and the resolved recipe must hold it. TOML v1.0.0 specifies 64-bit signed integers and an error beyond them.tomllibhappens to read the larger value, but another TOML reader need not, so every unseeded run’s stored recipe would fall outside the standard. A scan’s derived seed isuint64(driver/_scan_model.py), half of them above \(2^{63}\).Arrays. “Arrays other than data files” are refused, yet
betas,phases,save_planes,StatsSpec.energy_windowsandFixedSchedule.stepsare typedSequence | ndarray, andbetashas a per-element unit. No layout is defined for the files of thebetatuple form or anIndexTable; S12 covers spectra only.
Recommendation: add a complex spelling (
{re = , im = }, say) and a decimal-string form for seeds to format 1. Say that a one-dimensional numeric array is written as a list up to a stated length and as a data file beyond it, and define each file layout, or defer those types by name.G4 — significant: table names collide with keywords, and the path grammar is too small (§5.1, §5.9).
gridis a keyword of all six routes andmediumof two, and §5.1’s example has[grid]and[medium]as group tables.[grid]withtype = ...beneath it could be either.outputis an S9 group and a refused key. A plain mapping such as[plasma]withHe = truelooks like a group with an unknown key. The text does not say whether unknown group names or top-level keywords are allowed..nameand[i]cannot address a mapping key that is not an identifier, andlaunchkeys contain a comma and a colon (HE1,1:even, measured on the capillary withmodes=3). In[pins]and[scan]a path has to be a quoted TOML key, which does parse ("pulses[1].energy" = ...), but the text does not say so.“A pinned path may not also be set literally” leaves no way to pin
pressure[1]and setpressure[0], because the array needs something in the pinned place.
Recommendation: rename the groups so that none is a keyword, require every top-level table to be a group or a reserved table, and refuse anything else. Add a quoted segment (
launch["HE1,1:even"].share) to the grammar, and a placeholder for a pinned element or a rule that only whole keywords are pinned.G5 — significant: the
[execution]table has four concrete defects (§3, §5.1, §5.14).boundary_checkis a keyword of all sixfrom_*constructors (measured), andmemory_budgetoffrom_waveguide, so both are build keywords and execution settings by §5.1’s definitions. §3’s statement thatfrom_*lacksboundary_checkis wrong.prop_radial,prop_freeandprop_planaralso takeabsorberandabsorber_scheme, which theirfrom_*do not and which fit none of §5.1’s three lists.The §5.1 example does not build as written.
fields = "planes"alone is refused: “field_planes is required exactly when fields=’planes’” (output/schema.py:3075).memory_budgetis “passed torun”, but §5.1’s surface isrecipe.build()followed bysimulation.run("rdw.h5"). Nothing carries it across, and settings has no such field.from_waveguide’s budget is another gate: it admits the guide’s tables at build and is refused withoutprovider(driver/simulation.py:4940), so S14’s “the same budget” is true of the unit only.Recipe.scan()’s builder cannot apply[execution] fields. Withfieldsset in a builder, the point’s status wasfailed(driver/_scan_run.py:404);run_scantakesfields=itself, andRecipe.scan()returns only theScanand the builder. Separately,Scanrequires aname, which also sets durable filenames and the derived seeds (name_seed), andbase_seedis optional.[scan]has room for neither.
Recommendation: take
boundary_checkas a build keyword. Fix the example withfield_planes. Have theSimulationhold the recipe’smemory_budgetfor itsrun, or addRecipe.run. HaveRecipe.scan()return therun_scankeywords too, and reservenameandbase_seedin[scan], withnamerequired.G6 — significant:
"none"collides on a listed execution setting (§5.1). Formemory_budget,Nonemeans the environment or the machine’s default and the string"none"means no check (driver/memory.py:231,driver/simulation.py:2900). So the test that “no keyword accepts both” fails on the table’s own key, and a loader that maps"none"toNonewould silently turn a recipe’s “no check” into a default budget. Recommendation: spellNoneonly where the default is notNone, which onfrom_gnlseandfrom_capillaryis exactlynoiseandboundary_checkby signature. Elsewhere leaving the key out already saysNone. State the test on that set.G7 — significant: the “changed after its build” rule errs both ways, and the group’s lifetime is undefined (§5.2, §6).
False alarm.
_adopt_recorded_seedredraws an unseeded realisation on a resume. Measured: after it, the hash equals the file’s and differs from the build’s. Sosolprop run x.toml --resumeon an unseeded recipe meets “writes no recipe group and warns”. The same happens to a scan builder that leaves the seed to_reseed(driver/_scan_run.py:401).Missed change. Replacing
fields,resume_history,backendorboundary_checkleaves the hash the same (measured;_EXECUTION_SETTINGS). A file can hold a resolved[execution]that is not what ran, and a resume through it uses the wrong retention.Cost. Comparing needs a hash at build, which is a translation, so §6’s “building a recipe adds milliseconds” no longer holds. R9 measured 0.13 s on the capillary, and a guide route builds its tables there.
Lifetime. “At run start” does not say what a resume does. SEP 42 §5.8 allows a top-level group “written once” and keeps the stored record unrewritten, and under it
solprop run edited.toml --resumemay continue with a changedrtol. A latersolprop resumethen returns to the first session’s numerics.
Recommendation: drop the comparison. The simulation carries its recipe, and each mutator (
with_input,with_mixture, a settings change outside the execution list) drops it and warns. Write the execution section from the settings at run start. Write the group once, never rewrite it, and let SEP 42’s execution records say how later sessions differed.G8 — significant: S5’s record conflicts with SEP 42, and S6’s has no place to go (§1, §5.5, §5.6, §6, §9). S5 records “through the input record that exists”. On the capillary that record is
configuration.initialbesideconfiguration.pulse(both measured, withinitialshaped(3, 1, 385)formodes=3). SEP 42 §5.2 makesconfiguration.initialignored whereconfiguration.pulseis present. So two recipes with the same pulse and differentlaunchwould continue each other’s files with nothing said, and the shares would be readable from nowhere. In S6, theAlignmentrecord belongs to a coupling step, and a free-space launch has none, so the fallback is empty; the fields would go inconfiguration.pulse. Q8’s decision already makes additive fields acceptable. Recommendation: state plainly that S5 adds alaunchrecord (configuration["launch"]already holds an incident report,driver/simulation.py:2393, so it needs its own name), that S6 adds fields toSpatialPulse, and that each has an SEP 42 neutral entry and a compared class. Remove the conditional schema text from §1, §6, §9 and the phase 4 report.G9 — significant:
solprop resumeandresumabilitydo not cover what exists or what SEP 42 adds (§5.2, §5.17).§5.2 continues through
resume=True. A dense run stopped mid-way was refused there (DriverError: “ends on a plane interpolated inside a step”) and continued underresume="interpolated"(measured). The command has no such option, though §5.17 and §7 name the case.SEP 42 adds
resume="force", a refusal of a file with a forced session, and “work left” judged by position and not status (its §4, §5.5). §5.17’s reasons have none of these.The gate needs a requested record, and
resumability(path)has only the file, so a moved optmat digest cannot be seen without a rebuild.“No recipe and so no rebuild from the file alone” is listed as a reason for “not at all”, yet such a file resumes from its script.
A file whose writer is still alive, and a recipe of a newer format, are not listed.
Recommendation: separate three answers: can the file structurally continue, and from which
z; can it be rebuilt from itself; and, on request and at the cost of a rebuild, does the gate pass. Givesolprop resumeandresume_filea mode. Add the missing reasons.G10 — significant: the stop’s description and its scan form do not match the code (§5.17, §6).
§5.17 polls “where SIGTERM takes effect today, between commits”. SIGTERM raises at once at whatever line is reached and is held only during a commit (
_interrupts.py:10-15). A poll at commit exit runs once per accepted step, so it does not answer during rejected trials,translate, backend selection or atomic preparation._GUARDis process-global andcommittinghas no per-run handle, so a flag polled there is not per run (read, not tried with two runs).run_scan(stop=): athreading.Eventcannot cross to aPoolworker, andPool._stopends withprocess.terminate()(driver/executors/pool.py:430), the abrupt exit on Windows that §5.17 was written to avoid. Batch, Queue and Slurm are not mentioned. The point loop catchesExceptiononly (driver/_scan_run.py:627), so aRunTerminatedleaves it. What happens to the point in flight is not stated.RunTerminatedneeds a signal number and reads “terminated by SIGTERM”, code 143 (exceptions.py:64). It is aSystemExit, which a front end’sexcept Exceptionmisses.
The core does work. A prototype that set a pending
RunTerminatedfrom another thread, with the run on a non-main thread, stopped in 34 ms, and the file resumed to completion. Recommendation: add polls at the run’s start and per trial, and state the latency: one step or trial. Make the flag run-scoped. Give the stop reason its own text. Specifystopper executor: Local polls, Pool sends a pipe message, Queue and Slurm stop between points or refuse.G11 — significant:
solve_parameterand[pins]are not implementable as written (§3, §5.1, §5.7, §7).("rdw", pump, rdw, peak_power=None)cannot reproducepressure_rdw:pressure_rdw(..., peak_power=2e9)raises “the largest constituent needs fwhm” (measured;tools.py:1954). The target also needsfwhm,shapeandnonlinear_phase. The §7 test as written would fail.The nonlinear targets need the fill’s Kerr coefficient at each trial value, and “a mode need not know its fill’s Kerr response” (
tools.py:1231). Sobuild_mode(value) -> modeis not enough forsoliton_order, or forrdwon a tabulated mode. The role of theparameterargument is not stated.§3 says the zero-dispersion search samples.
pressure_zdwbrackets once withbrentqand refuses equal signs (tools.py:2369); the sampling is over wavelength, inside the modes’ ZDW search. A sampled ZDW search over a parameter is new work, not reuse, and the “equalspressure_zdw” test holds only for a single root.The
rdwandsoliton_orderpin tables are not given, nor which mode a pin solves on, nor which \(z\) on a pressure gradient. No syntax lets a target read a path. If the implicit read set is the mode’s inputs, any two pins on mode parameters read each other, so a cycle is always refused and the dependency order is never used. That part is a judgement: over-built, or a read set is missing.The front-end design asks for
solve_pin(recipe, pin)with its roots, and lists a pin giving pressure and energy fromcapillary_design. §5.1 has no call that returns a solved value or the roots, and no two-output pin. Without such a call a front end has to write its ownbuild_mode, which is the physics the principle of §2 keeps out of it.§5.10 counts pins as “metadata level” and §6 says
check()costs a translation. Measured on the Marcatili capillary on a busy machine:pressure_zdw0.47 s,pressure_rdw0.14 s linear and 0.40 s with the nonlinear phase, first-call costs included. A non-Marcatili builder pays up to 258 mode builds.
Recommendation: have the builder return a mode and its fill, or take a
gamma(value). Carryfwhm,shapeandnonlinear_phasein the target. Define each pin table, the mode and the \(z\). AddRecipe.resolve()andRecipe.pin_roots(path). Either define read sets or drop dependency order from format 1. Count pins incheck()’s cost.G12 — significant: “node and memory budgets apply” is not a limit on a received file (§5.2, §7). With
memory_budget=Nonethe machine’s four fifths “only warns” (driver/simulation.py:2903). A recipe can itself saymemory_budget = "none", andnode_budgetis a build keyword it can raise.from_gnlsehas nonode_budget. So a received file chooses its own limits. Also, §5.2 reads embedded files into memory, butRecipe.from_output(...).save(...)then holds afile =with nothing behind it, and embedding has no size limit (“usually small”). Recommendation: inresume_fileandRecipe.from_output, ignore the recipe’s own budgets and apply the caller’s, refusing by default. Say what saving such a recipe does with its data. Cap what is embedded, and record a hash and a path beyond the cap.G13 — significant: the phases have dependencies the plan does not state (§11, header). Each phase can otherwise reach a green gate, as far as I can tell from reading it.
Phase 2 verifies embedded data files, but the only way to load one,
load_spectrum, arrives in phase 5, and{type = "DataPulse", file = ...}is notDataPulse’s constructor. Move S12 before phase 2, with its entry in the registry.The header says Requires —, yet S2 and Q8 rest on SEP 42. §9 and Q4 call it “in draft” and it is now Accepted.
S12 needs the
DataPulsefix, which is not on this revision (pulses.py:2102,pulses.py:2182). SEP 42 §9 says it takes the output schema to 2. So §7’s “schema-1 file” is stale, and §9’s “no computed field changes” and §6’s “unchanged by … S7–S18” need that exception.Recipe.scan()(phase 3),Recipe.modes()andpreview_input()(phase 4) all read pinned values, and pins come in phase 5. Phase 5 should list those retrofits.
G14 — significant: §7 leaves new behaviour unverified.
S6 has no entry: the band and edge refusals, energy after a tilt, the agreement with
Alignment.S16 and S18 have none.
preview_inputshould equal the run’s initial field, and each S16 refusal should match the reader’s actual refusal, or the rules drift.load_spectrum’s phase rules andphase_conventionhave none; “Loaders” tests energy and centre wavelength only.Equal hashes cannot catch a
to_python()that dropsfields,boundary_check,memory_budgetorbackend, none of which is hashed, so §1’s “gives the same run” is not what is tested.
Recommendation: add these, and compare the execution settings in the export test.
G15 — minor: the entrance plane is not defined for the observer (§5.3, §7). The entrance is saved without a
plane()call (driver/run.py:485-505, against:580). Measured: at the secondplane()the file held three rows. So “once per save plane … beforeplane()” and “each plane” disagree there. Recommendation: say that the entrance is observed. Say also that a resume does not re-deliver earlier planes, which a front end reads from the file.G16 — minor:
observe_boundaryis undefined in its details (§5.3). The monitor has no verdict: it warns once or stops (monitor.py:706), and the word appears only for the collar’s quiet flag (backends/driver.py:1990). It runs insidepropagate_to(backends/driver.py:1974), so a call there falls outside “after the plane is committed”, and the failure policy names onlyobserve_plane. Aruntime_checkableprotocol checks every member, so the optional method needs a protocol of its own. Recommendation: define the verdict’s type, the call site and the failure policy.G17 — minor: S6 leaves the order and some routes open (§4, §5.6).
SpatialPulse.buildappliespropzspectrally (pulses.py:296), and whether a tilt and offset apply at the waist or the launch plane differs by \(\mathrm{propz}\tan\theta\).A \(k(\omega)\) screen makes the field non-separable, while
buildmultiplies one transverse spectrum by the temporal one, so the normalisation has to change.“A beam’s displacement and an
Alignmentadd” fails onprop_waveguide(incident=...), which samples the beam on a radial grid (driver/simulation.py:9985), where S6 refuses any nonzero value.A
LineGridhas no \(y\).A tilted pulse front is delayed by about \(x\sin\theta\,n/c\) across the beam, which belongs among the validity conditions for the time window.
G18 — minor:
load_spectrum’s defaults and heuristic (§5.12).axis_unit="Hz"stays the default withaxis="wavelength"; it should followaxis.columns=(0, 1, 2)names a column that “is optional”.The “small fraction” is not a keyword, though it changes what a recipe builds.
The wrapped test has no override, and a wrapped column in degrees spans 360 and is taken as unwrapped radians with nothing said. A wrapped column undersampled past \(\pi\) per sample cannot be detected, and it deserves a warning: at 1.6π per sample the recovered slope was −0.25 of the true one (measured through today’s
from_spectral_density).The hold rule applies to wrapped columns only, so an unwrapped column’s noisy floor still feeds the spline. That is a judgement.
The sign rule itself is right (see below).
G19 — minor: S9’s
kindcannot describe the keywords (§5.9). It is singular and has no mapping, whilepressurehas four forms,plasmais bool, object or per-species map,lossandmodesare unions, andgas,ramanandlaunchare maps. A front end needs these. Recommendation: add a list of forms, each with its kind and units.G20 — minor: S14’s “
noise,seed: add to planar” is contradicted by the code (§5.14). “A planar grid carries no noise draw” (docs/guide/media/noise.md:255), for the reason inPLANAR_NOISE(driver/simulation.py:484). Adding it means declaring a photon measure, which §2 rules out. Recommendation: list it as a documented difference.G21 — minor: S8 and S10 contradict each other (§5.8, §5.10, §7).
parameteris set “wherever the interface layer refuses a value”, value refusals “are not rules”, and the lint requires every refusal that setsparameterto name a rule. Recommendation: lint combination refusals only, by a marker on the raise.G22 — minor: S18’s previews are underspecified (§5.18).
betaand the rest depend on \(z\) on a gradient (_zdw_near(mode, omega, z)), and no \(z\) is given. On a guide route the dense tables are built at construction, so “the mode solve only” is the large cost, and it needs the memory admissionfrom_waveguidemakes. The text also does not say whetherpreview_inputincludes noise, and an unseeded preview would otherwise draw.G23 — minor: “as written” and the writer (§4, §5.2, §6, §7, Q7). §5.2’s
authoredis “canonical … as written” and §6’s is “stored verbatim”, and these differ: a canonical writer turns"0.8 um"into"800 nm"and drops comments. §7’s “equal recipe” needs a meaning. Q7’s display unit says µm and the format’s strings sayum. Recommendation: keep the loaded text verbatim, compare recipes by value, and fix one ASCII spelling for writing while reading both. That saving a hand-written recipe loses its comments is a judgement for the maintainer.G24 — minor: a scan’s file holds no recipe (§2, §5.2). The collected file has
common,executions,layouts,points,scanandstaged(measured), and S2 speaks of a run. §2’s “reopen a results file with the setup that made it” therefore stops short of aRecipe.scan(). Recommendation: store the authored recipe in the scan file, or say that it is not stored.G25 — minor, a judgement: the registry’s size is public surface (§5.1, §9). §9 weighs renaming route keywords only, but every keyword of every registered type also becomes part of format 1, including
TDSEBasis(14 keywords),HaccBasis, andMultistep’sequal_approachandmax_growth. Recommendation: register the documented user-facing types first, and letfrom_callrefuse the rest towards the Python export. Adding an entry later is additive; removing one is not.
Verified and found correct#
§4’s decimal rule: the example’s values and others convert to the script’s literal, where naive products fail (
800,7.5,275,100). 200,000 random doubles written in a chosen prefix and read back had no failures. The default decimal context rounds at 28 digits, so an implementation should use a local context.The §5.1 capillary example builds, apart from
fields(G5).A rebuild with an explicit copy of a drawn seed has the file’s hash, and an unseeded rebuild adopts it.
wavelength_limitsas a list,pressure=1against1.0, andpressure=Noneagainst1.0do not move the hash.An unknown top-level
recipegroup in a written file reads, resumes and survives, which agrees with SEP 42 §5.8.RunTerminatedraised from a reporter after a commit leaves a file thatresume=Truefinishes.§5.12: a phase of \(+\omega\tau\) overlaps a pulse with
t0=+tauand not-tau, so the sign statement is right. Today’sfrom_spectral_densitydoes lose a phase step above \(\pi\). The three-column layout agrees with the other code’s documentation; I did not check its sign.§4’s tilt matches
Alignment’s definition (read). The capillary’s labels display asHE1,1:even.
Not checked#
tomli-w: it is not in the shared environment, so its writing of large integers and nested tables is untested.The native and device backends.
PlaneView’s neutrality and device fetch were not prototyped.The guide and provider registry of phase 4, beyond the signatures, and
solve_parameteron anything but the existing Marcatili tools.S4’s pipe message, S11, S13, Windows, and Queue or Slurm behaviour, which was read only.
SEP 42 has no code yet, so every statement about it comes from its text.
No test suite and no long run were performed. Capillaries were built and not run; the runs were GNLSE fibres of at most 30 cm with a handful of saved planes, and a one-point scan.
Response to the supplementary review — 2026-11-01 (Opus 5.5, drafting agent)#
G1 was re-run before this response (dz_init moves stepper.limits.auto_init, and
raman_fraction moves materials.policies.gnlse_raman.origin), and the rest of the numbers
quoted are the reviewer’s. The revised format was tried with tomli-w 1.2.0 and tomllib,
including nested tables and quoted path keys, and the §5.1 example’s values build on the
capillary. The maintainer gave leave to reopen decisions and approved text for a better and
more consistent result, and Q9–Q12 list what that reopened. All 25 points are taken, with
the departures stated in points 2, 4, 7, 10, 11 and 16.
G1 taken. The resolved recipe is defined by what rebuilds the run (§5.2), with a resolvable flag in the metadata and a sync test.
backendstays as requested. A keyword that cannot qualify stays left to the build, and the resume gate notices a moved default.G2 taken, more simply than recommended. Entries are named factories with a declared build context, which format 1 limits to the route’s
grid. The inverse is dropped altogether: no object becomes a table, sofrom_callgives way tofrom_keywordswith plain values (Q11). The §7 tests run table → object → hash, with hand-written recipes, and the examples without a recipe are listed and checked.G3 taken. Complex numbers, decimal-string integers, and lists up to a stated length with data files beyond (§5.1).
IndexTableand thebetatuple form are deferred by name.G4 taken, by a different route. Rather than renaming groups, the format drops them: one
[build]table and four other fixed tables (Q9), so no future keyword can collide. This reopens the example the maintainer approved. The path grammar gains["key"], and only whole values are pinned.G5 taken.
boundary_checkis a build keyword, §3 is corrected, the example hasfield_planes,Recipe.runcarriesmemory_budget, andRecipe.scan()returns therun_scankeywords, withnamerequired andbase_seedreserved.absorberon the object routes goes undersettings.G6 taken.
"none"is spelled only where the default is notNone.G7 taken, with one addition. The simulation carries its recipe and mutators drop it; the group is written once and never rewritten. Execution settings come from the run start. The built hash is no longer stored, since it would only repeat the file’s own stamp.
G8 taken. S5 adds
configuration.modal_launch, S6 adds two fields to the pulse record, each compared with a neutral entry, and the conditional schema text is gone. This also settles Q8.G9 taken.
resumabilityanswers three questions,gate=Truecosts a rebuild, andsolprop resumeandresume_filetake a mode (§5.1, §5.2, §5.17).G10 taken, with a further change. The stop polls before the first step and per trial, is per run, and is defined per executor. Beyond the point: a run stopped by its own flag returns rather than raising (Q10), because a
SystemExitends a worker thread silently and missesexcept Exception, and the reviewer’s own text-only remedy would leave that.G11 taken, more simply than recommended. Targets are typed and carry what they need, and
buildmay return the Kerr coefficient.Recipe.resolveandpin_rootsare added. Instead of read sets, pins resolve in two fixed stages with at most one mode-parameter pin, so cycles cannot be written.capillary_designis not a pin: it sets two values, and a front end writes them as literals. §3’s account of the search is corrected, and a sampled zero-dispersion search is stated as new work.G12 taken.
resume_fileapplies the caller’s budgets and refuses by default; embedding has a cap;checkreports a file left dangling after saving (§5.2).G13 taken. S12 moves to phase 1, Requires names SEP 42, §7’s stale schema text goes, and phase 5 lists its retrofits. The
DataPulsefix lands separately (LupoLab/solprop#70), and §5.12 only relies on it.G14 taken. §7 gains tests for S6, S16, S18, the phase rules and the execution settings.
G15 taken. The entrance is observed, and a resume does not repeat planes.
G16 taken by removal.
observe_boundarygoes: the monitor warns once or stops, and a front end has both, so a hook would have no verdict to carry. The front-end design’s lamp reads those instead.G17 taken. Tilt and displacement apply at the launch plane, after
propz; the energy is set after the screen;incidentand the line grid’syare refused; the pulse-front delay joins the validity conditions.G18 taken.
axis_unitfollows the axis, a two-column file is allowed by default, andwrapped,floorandphase_unitare keywords, because they change what a recipe builds. Undersampling warns. The hold rule stays for wrapped columns only.G19 taken. The metadata has a list of forms.
G20 taken. Planar noise is a documented difference.
G21 taken. The lint covers combination refusals, marked at the raise.
G22 taken.
modestakesz, its cost and admission on a guide route are stated, andpreview_inputdraws no noise.G23 taken. The text is kept verbatim until a recipe is edited, recipes compare by value, and writing uses ASCII while both spellings read. Losing comments on an edit is Q12.
G24 taken. A scan’s file stores the authored recipe.
G25 taken. The registry starts with the documented user-facing types, and §9 says why.
Consistency. The front-end design was revised to match: recipe sketch, pins, the boundary lamp, and the service table.
Still unmeasured, as the review says: the native and device backends, and PlaneView on
them; the guide and provider registry of phase 4; solve_parameter beyond the Marcatili
tools; Windows; Queue and Slurm. Nobody independent has read this revision.