The timestep as a transaction: model.step and a run transcript you can watch, publish and read back - #716
The timestep as a transaction: model.step and a run transcript you can watch, publish and read back#716lmoresi wants to merge 11 commits into
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
There are a few correctness and reliability issues in newly-added guards/docs (string-based drift comparison, a brittle error path, and a doc inconsistency) that should be fixed before merging.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR introduces an explicit timestep boundary (with model.step(dt):) as a transactional unit of work, records step activity in a journal (optionally with restorable snapshots for rewind/replay), and addresses several previously-silent defects discovered while building that mechanism (time-dependent mesh.t, disk snapshot of dimensional tracker values, constants-slot collapse, and FreeSurface derived-solver drift).
Changes:
- Add
ModelStepjournaling +model.step(dt)context manager, plus optionalrecord_everysnapshots andmodel.rewind()support. - Make
mesh.ta live rampable constant synced frommodel.tracker.timebefore each solve; hook solve + history-shift events into the step journal. - Extend disk snapshot serialization to round-trip dimensional quantities (magnitude + unit string), add regressions, and wire new FreeSurface drift guard + constant-slot guard tests into the test runner.
File summaries
| File | Description |
|---|---|
| tests/test_1074_free_surface_config_drift.py | Regression coverage for FreeSurface derived-solver drift refusal + negative controls |
| tests/test_0104_constant_slot_still_constant.py | Regression coverage for constants[] slot “stops being constant” error + recovery path |
| tests/test_0011_model_step_journal.py | Regression coverage for model.step semantics, journaling, recording, and rewind/replay invariants |
| tests/test_0009_model_tracker.py | Regression coverage for mesh.t tracking model clock and dimensional tracker round-trip on disk snapshots |
| src/underworld3/utilities/unit_aware_coordinates.py | Make time-units patching tolerant of new mesh._t implementation |
| src/underworld3/utilities/_jitextension.py | Refuse to silently pack 0.0 into a stale constants[] slot; raise diagnostic error |
| src/underworld3/systems/solvers.py | Fix source-term setter to avoid baking UWexpression atoms by mistaken quantity duck-typing |
| src/underworld3/systems/free_surface.py | Add derived-solver drift detection and refusal before solving |
| src/underworld3/systems/ddt.py | Emit journal events when histories shift to detect “advanced twice per step” cases |
| src/underworld3/model.py | Add step journaling/recording/rewind API and model.step(dt) transactional context manager |
| src/underworld3/discretisation/discretisation_mesh.py | Implement mesh.t as model-time-backed constants[] atom; sync it from model before solves |
| src/underworld3/cython/petsc_generic_snes_solvers.pyx | Central hook: sync mesh.t + record solve events in the open step journal |
| src/underworld3/checkpoint/disk_snapshot.py | Add dimensional-quantity serialization (magnitude + unit string) for disk snapshots |
| scripts/test.sh | Ensure new test_1074 is actually executed by the script runner |
| docs/developer/guides/HOW-TO-WRITE-UW3-SCRIPTS.md | Document the new timestepping pattern, journaling/recording/rewind usage, and mesh.t behavior |
Review details
- Files reviewed: 15/15 changed files
- Comments generated: 4
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| def note(name, what, mine, theirs): | ||
| if str(mine) != str(theirs): | ||
| drift.append(f"{what} — free: {str(mine)[:60]} | {name}: {str(theirs)[:60]}") | ||
|
|
| raise RuntimeError( | ||
| f"constants[] slot {idx} ({uw_expr.name!r}) no longer " | ||
| f"reduces to a number, so the compiled kernel — which " | ||
| f"treats it as a scalar constant — is out of date.\n" | ||
| f" current content: {str(getattr(uw_expr, '_sym', uw_expr))[:160]}\n" | ||
| f"This usually means an atom nested inside it has been " | ||
| f"ramped, and the expression has stopped being constant. " | ||
| f"Force a rebuild before solving again:\n" | ||
| f" solver.is_setup = False\n" | ||
| f" solver._needs_function_rewire = True\n" | ||
| f"To keep a coefficient rampable without this, give it its " | ||
| f"own atom rather than letting the enclosing expression " | ||
| f"collapse to a number at compile time — see " | ||
| f"uw.maths.functions.vanishing." | ||
| ) from None |
| - A step warns when a history advances more than once | ||
| - Set `.sym` to change a value; rebinding the name changes nothing | ||
| - Backstepping recipe; snapshot before the operator | ||
| - `mesh.t` is not the model clock and is silently zero in a solve |
| ``steps=1`` returns to the beginning of the most recent completed step, | ||
| undoing it. Fields, histories and the clock all come back together, | ||
| because the clock lives on the tracker and the tracker is captured with | ||
| everything else. |
…its are chosen before the mesh Every example hand-rolled its own `t += dt` in a loose variable that no snapshot could see, and set reference quantities after the mesh existed, when it was too late for them to take effect. The guide states the pattern once: the clock on `model.tracker`, reference quantities before the mesh, and why each of those is the only place the choice can be made. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V
… record of what it did A run had no step boundary, so nothing could say what a timestep did, whether it finished, or what state it started from. `with model.step(dt):` owns a time interval: the clock reads its end for the whole block, because an implicit residual is centred at t + dt; the advance commits on clean exit and only then, so a rejected or failed step leaves the clock alone; and everything the block did — each solve, each history shift — is recorded in order. Three defects found by building it, each silent: mesh.t was zero in every solve (it now resolves to the model clock, #410); the on-disk snapshot dropped dimensional values; and a constants[] slot that stopped being constant packed a zero rather than saying so. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V
… disk, and draws it `model.record_every` keeps a snapshot of the state a step started from, and `model.rewind()` returns to it — fields, histories and clock together. Replaying a step from its snapshot is bit-identical; re-running the script is not, because iterative-solver history is not model state. That is what lets a step that misbehaved be looked at twice. The record is written as it happens: one aligned line per step, flushed as each closes so a running job can be watched with `tail -f`, with every backtrack in it — a log that shows step 7 and then step 7 again with nothing between is not a log of what happened. JSON lines beside it for reading back. Rendered as a figure, time running down the page, SVG for the web and a PDF written without a plotting dependency. Two more silent defects: a snapshot rescaled the mesh under units (capture read metres, restore wrote model units); and a solver on a rotated boundary was recorded twice because a setup push re-announced it. Plus a guard for free-surface derived solvers whose copied configuration had drifted from the parent. An annulus convection case is the second worked example. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V
…than in the loop, and left by default Named: journal, record (noun) and log become the transcript — what the run actually did, false starts and re-takes included. Record stays the verb. An earlier revision asserted a rule inside the loop — a history must advance exactly once per step — and it was wrong twice over: it fired on legitimate sub-cycling, and it could not see the checks that matter, which are growth rates across steps. It is gone. The step records; a pass over a finished transcript judges. The design note sets out what such a pass can read from the record and what it cannot yet. Every run now leaves its transcript without being asked, in ./transcripts/<stamp>-<script>/ with the launch script kept beside it, both formats, a `latest` link and a terminator — so the run from this morning can be found, and run again. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V
…ch solve went, and the figure marks it `solver.describe()` returns the residual templates as implemented, named expressions expanded recursively into the constitutive model, the boundary conditions including rotated free-slip, and the terms the solver was given. `view()` renders it and the transcript serialises it, so a note and a run cannot quote different equations. A solver adopts the contract by declaring a roster, `_solver_terms`, and a test pins the set that has not, in both directions. Each solve event carries its outcome — converged reason, iteration counts, |F|, and whether a fieldsplit block hit its cap, which is neither a success nor a failure and is recorded as such. Warnings raised inside a step are recorded with their origin. The figure marks the three states per solve; the text log's outcome column names the failing solve under its step. Named plainly: `transcript_table` and `transcript_figure`; the musical vocabulary lives in the design note as derivation only. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V
…arm advection, one solve one event, the warnings shim A semi-Lagrangian solver carries the history of T and the history of its flux; both shifted every step under one label, which read as one history advanced twice. The flux history is now `SemiLagrangian(F[T])`, with the tracked expression and the velocity levels the trace-back read on the event. Swarm advection is recorded, with the particle count before and after — a swarm that quietly lost particles to the boundary is the kind of thing a run should say. From review: `_update_constants` ran on a parameter change and the continuation toggle as well as before a solve, so one solve wrote several solve events — only the solve() bodies record now. The warnings shim takes a four-argument hook, restores the previous hook only if the shim is still installed, and tags each warning with its rank; the log deduplicates identical warnings with a count. `model.part_object()` returns the live object behind a recorded part. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V
9d25a65 to
3185edb
Compare
Adversarial review — the records (2026-09-14)An independent reviewer running probes against the branch. Findings that survived, and what was done; fixes are in the branch's final commit.
Attacks that failed: np=2 swarm advection in a step, step open on one rank, abandoned step — no hangs, counts agree; outcome matching by part with inner projections; SVG well-formed with the emoji; PDF structure and xref offsets correct. Also: |
… what each part solves Validation of the annulus example against the log's readability: - Parts are named by the uw.systems name the script used (AdvDiffusion(T), Stokes(v)), not by the implementing class; the operator text lives in one place, model.py. - The log header carries the script name where the model was left as 'default', and local time with its offset, so the header agrees with the directory stamp. - The figure and the chart read the on-disk record first, so abandoned and rewound steps are in them, not just the steps still in memory. - uw.transcript_key renders what each part solves: the named quantities in its residuals with values, units and descriptions, and its boundary conditions, as Markdown+LaTeX or text; transcript_figure(key=True) appends the same beneath the chart. describe() now records a value that is itself a named expression. - The figure draws each step as a bar in which what ran sits one line below what ran before, in its own column, joined by a path: the shape of the path is the sequence, a step taken twice is twice as tall, and the columns sit in the order the parts first ran. The order digits are gone. - The PDF spells out Greek and sub/superscript symbols the base-14 fonts do not have. - The table's header is as wide as the longest label; legend rows appear only for marks that occur. The blog post is rewritten around the log, and its figures regenerated, including the key. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V
The key beneath the chart wrote its symbols as plain text — a^AM₀,₁₈ for a superscripted, doubly subscripted coefficient, and ? for anything the PDF's base-14 fonts lacked. Each line's symbol and value are now set from their LaTeX as glyph outlines (matplotlib's mathtext, no TeX needed) and drawn as paths in both the SVG and the PDF, so the figure carries real mathematics and needs no font at all to display it. Where mathtext cannot set a form (a matrix), or matplotlib is absent, the plain-text line is written as before. The DDt scheme coefficients describe themselves in prose — "Adams-Moulton coefficient 0 of history 18" — rather than in the LaTeX they used to carry into the key. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V
Each link in a step's path now runs level, drops halfway between the two columns, and runs level again. The sequence is a ticker — one thing, then the next — and a sloped line suggested a continuous transition that is not there. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V
The key now carries the equations themselves, not only the quantities in
them: the residual statement, then each form — f_0, F_1, h_0 — with its
docstring above it and set from the LaTeX the solver recorded, the same
description view() renders in a notebook. SymPy writes every vector and
tensor form as a matrix environment, which mathtext has no way to set, so
the cells are laid out on a grid with drawn brackets. A form wider than the
page is set to fit: a row vector is first stacked into a column, then the
whole equation is scaled, and it stays vector, so the SUPG flux is small on
the page and legible on zoom. A variable whose name starts with an
underscore (the mesh's _h_cell) reaches LaTeX as {_h_cell}; it is set as
h_cell.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V
The Adams-Moulton coefficients were floats — a^AM = 0.5 — and the SUPG tau carried a literal 1e-30 in every denominator and a 64.0 from the squared Peclet weight. A float cannot cancel, and each printed as a decimal in the form the run records. The coefficients are now sympy rationals (theta is made exact at the boundary with nsimplify), the Peclet weight is exact inside the scheme and a float at the property, and the regulariser is the library's named vanishing value, which prints as epsilon and is listed in the key with the rest. The Style Charter's API table gains the rule: a number in a symbolic form is exact where it is exact, and a regulariser is uw.maths.functions.vanishing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V
What this is
A run had no step boundary. Nothing in the library could say what a timestep
did, whether it finished, or what state it started from — so nothing could
check it, replay it, or walk it backwards.
This adds that boundary, a log of it you can watch a run through, a figure of
it you can publish, and fixes eight defects found by building it and then by
using it. Each was silent: a wrong answer or a change that quietly did
nothing, never an error.
The pattern
model.step(dt)owns a time interval. The clock reads the END of that intervalfor the whole block, because an implicit residual is centred at
t+dtand adriven boundary must be evaluated there — committing only on exit would put
every implicit coefficient one step late, which is a first-order error that
looks right and converges. The advance commits on clean exit and only then, so
a step that raises or is rejected on a Courant check leaves the clock alone.
Everything the block did is recorded in
model.transcript, named by what itsolves. That record answers what a run actually did without the script being
instrumented, which is the question you want to ask of someone else's model.
model.record_everyasks each step to keep the state it started from, andmodel.rewind()undoes a step: fields, history and clock together. Replaying arewound step from its own snapshot reproduces it bit for bit, where
re-running the script does not — warm starts and preconditioner reuse are
solver history outside model state, so two independent runs diverge at 1e-13
from the first step. That asymmetry is why a step that misbehaved can only be
looked at twice this way.
Where a run's transcript lands
On by default. A run that takes a step leaves
The account is only worth having on the run you did not prepare for — requiring
opt-in defeats the case it exists for. The stamp is why it is a stamp: the run
you want is the one from this morning. A directory rather than loose files
because a working directory full of logs and script copies invites mass
deletion.
launch.jsonis the honest answer to reproducibility. A programmatic launchercannot be made reproducible by fiat, but what was actually run can be written
down: command line, interpreter, cwd, package version, and the commit id with a
dirty flag when cwd is a repo. Only the entry script is copied — anything it
imports is not, and the file says so.
Three things make the default tolerable, each tested: nothing is created
until the first step opens, so an import or a mesh-only script leaves no
trace; it is off under pytest; and it is switchable —
model.transcript_file = None, an explicit path (which bypasses the stampeddirectory and gets no launch record), or
UW_TRANSCRIPT=off/UW_TRANSCRIPT=/scratch/runs.One aligned line per step, appended and flushed as the step closes, so
tail -ffollows a running job.A true log records the backtracks, so
rewind()and a bareload_state()eachwrite their own line — a log that shows step 3, then step 3 again with nothing
in between, is not a log of what happened. Four other things reach the file that
the bounded in-memory transcript does not keep: an abandoned step, a step aged out
by
transcript_limit, and everything up to a kill.wall/sis how long the block took; a step that suddenly takes ten times aslong is the first sign of a solver in trouble.
A
.jsonlsuffix (ortranscript_format = "jsonl") writes the same record as oneJSON object per line, read back with
uw.read_transcript. JSON lines rather thanYAML because one self-contained record per line is the point: a killed run
leaves a truncated final line that fails to parse, so the reader drops it and
keeps everything before, where a half-written YAML mapping frequently still
parses as a real record with its last key missing. YAML stays the right format
for a document written once and edited by hand, which is what
Model.to_yamluses it for. The two formats differ deliberately: text is a report (one time
column, one unit, named in the header), JSON is a record (every value in the
units the run actually held it in).
The figure
A green-on-black terminal is not where a run belongs in a paper.
Time runs down the page: one row per step, A4 portrait, paginated, so the
figure drops into a document column. The PDF is written directly — base-14
fonts, Flate-compressed content streams, a real cross-reference table — so a
run becomes a figure with nothing installed.
.svgwrites the same layout asone continuous page.
The letter is the layout. Each distinct operator sequence gets one, defined
once at the foot:
A column of
Awith a singleBin it says at a glance that one step didsomething different. A hundred spelled-out sequences say nothing and hide the
one that matters. Backtracks are drawn in the left gutter as the path the run
took — a dashed arrow up from the step it bailed out of to the step it returned
to, then a solid arrow down to the row that takes that step again — and the
dtaxis goeslogarithmic when the range exceeds 20x and says so, since a rejected step is
often tens of times the accepted ones — which is why it was rejected — and on
a linear axis it flattens everything else to nothing.
model.clear_transcript()starts a new run's account. A driver that runs the samemodel many times — an inversion, a parameter sweep, a restart — otherwise gets
the concatenation of every run the process has done, and
rewind()walks backinto the previous one.
Nothing is compulsory. A script that never opens a step behaves exactly as
before and the recording calls are no-ops.
Naming
A transcript is what was actually played, false starts and re-takes
included, which is precisely what this holds — and it carries its own contrast
with the score, the structure a run is supposed to repeat. "Journal" said
only "a log of some kind", and collides with the publishing sense in a
scientific codebase.
recordis kept as a verb: a step records what itdid; the thing it produces is the transcript.
docs/developer/design/run-score-and-transcript.mdis the design note behindthat — the vocabulary (bar, beat, part, note, rest, tuplet, tie, tempo), what
each makes checkable, and the three things still missing: barriers are not
events, rests are not recorded, and a bar is not necessarily an interval
although
model.step(dt)insists that it is. No implementation in it.Two worked cases
The pattern is written up in
HOW-TO-WRITE-UW3-SCRIPTS.md, and two runnablecases exercise it in different geometries.
docs/examples/convection/intermediate/Ex_Convection_Annulus_Recorded.py—Boussinesq convection in a 2D annulus. Four reference quantities fix all four
dimensions, and the buoyancy is then written as the force it is
(
-RHO0 * ALPHA * GRAVITY * T * rhat) so the Rayleigh number falls out of thenondimensionalisation rather than being typed in; the script prints
Ra = 2.585e+06as a check. Rotated free-slip on the curved boundaries, and avarying
estimate_dt()that goes straight intomodel.step(dt). After theloop it demonstrates the four things the record buys, in order: the transcript, a
rejected step that leaves the clock where it was, a bit-exact replay
(
max |dT| = 0.000e+00), and a step taken twice showing as its ownoperator sequence.
Compare
../advanced/Ex_Convection_Cylinder.py, which solves the same physicswith a bare
for step in range(n)loop and no clock at all.Reproducing a run from its record.
read_transcript()gives the stepsback in order, each with the interval it covered and the operators it applied,
and a run kept with
record_everycan be re-entered at any recorded step withload_state(entry.snapshot). A result is then reproducible from the recordrather than from a re-run of the script — the same state, the same operators,
in the same order — without anyone having decided in advance which arrays
would be wanted.
The defects
mesh.twas silently zero inside every solve (#410). It was bound toPETSc's
petsc_t, which the high-levelsolve()wrappers never set, andsolve(time=...)was accepted and ignored. A time-dependent boundary conditionwritten as
sin(omega * mesh.t)— the usagemesh.t's own docstringadvertises — was identically zero, and nothing warned. Now a live-rampable
constants[]atom carryingmodel.tracker.time, repacked in_update_constants, which is the single choke point every solver alreadypasses before solving. No kernel is recompiled per timestep and no solver
needed its own hook. Follows the #410 ruling rather than repairing the PETSc
plumbing that ruling records as tried and failed.
A dimensional value on the tracker was dropped by the on-disk snapshot.
A pint quantity fell through
_serialise_field's unserialisable branch, waswritten as
__skipped, and was absent afterload_state. With referencequantities set,
estimate_dt()is dimensional and so is the natural clock — sothe entry the pattern asks for was exactly the one a restart lost, while plain
floats beside it survived. Now stored as magnitude plus unit string.
A
constants[]slot that stopped being constant was packed as 0.0. This isthe "rampable constant in exponent position does not ramp" report, and the
mechanism is not what that report assumed. The atom is not compiled out.
(1 + T**2)**(-m) + 1is the NUMBER 2 whilemis zero, so the wholediffusivity banks as one constant and the collector stops there. Ramp
mandit depends on
Tagain, the slot can no longer be reduced, and the solvereceived a zero diffusivity: measured 2.0, then 0.0, then 0.0, with a
DIVERGED_LINEAR_SOLVEand nothing saying why. Now raises, naming the slot,showing its content, and giving the two lines that force a rebuild.
A free surface whose derived lids had drifted solved anyway.
heldandconsistentare separate Stokes solvers configured once, at construction.Change the free solve afterwards and they keep the old values: measured
viscosity 1000 against 1, body force -5 against -1, tolerance 1e-11 against
1e-6.
h_inf, the equilibrium the surface relaxes toward, comes from the HELDsolve — so the surface relaxed toward an equilibrium computed with stale
physics while the free solve used the new.
solve()now refuses and names whatdrifted.
An abandoned step held on to its snapshot. Nothing could reach it — the
record never joins the transcript — so it was field-sized memory pinned until the
exception's traceback was collected.
A snapshot rescaled the mesh whenever units were active.
mesh.X.coordsisthe unit-aware view and returns metres once a model declares a length scale;
the DM coordinate vector
_deform_meshwrites back into holds model units.Capture read the first and restore wrote the second, so every restore
multiplied the mesh by the length scale — a 500 km box came back
250,000,000 km across, compounding on each round trip. Nothing raised: shapes
matched and every field was restored correctly, so only the geometry was wrong.
The visible symptom is
uw.function.evaluatereturning the value at one cornerfor every sample point, because every sample point is now outside the domain.
model.rewind()goes straight through that path, which is how it surfaced. Theswarm path was unaffected — it captures the raw
DMSwarmPIC_coor.The transcript counted one operator as two. The hook lives in
_update_constants, which every solver passes on its way to a solve — exceptthat
solve_rotated_freeslippushes constants a second time for its ownassembly, after the public
solve()has already announced the solver. So aStokes solve on a curved boundary recorded twice, in a record whose whole value
is that it says what ran.
_update_constantsnow takesrecord=Falsefor asetup push.
estimate_dt()lost its units under the pattern's own idiom.dt = fraction * solver.estimate_dt()came back as a bare float whenever theestimate was a Python float rather than a numpy scalar:
np.squeezepromotesit to a 0-d array,
uw.dimensionalisemaps an array to aUnitAwareArray, andthat drops its units under arithmetic. The guard for exactly this already
existed in
_dimensionalise_dtbut sat on the no-units branch, soStokes.estimate_dt(numpy scalar) was fine andAdvDiffusion.estimate_dt(Python float, accuracy basis) was not — the two silently disagreed.
Recording, not judging
The step's job is to record faithfully. It does not decide whether what it
recorded was a mistake.
An earlier revision of this branch asserted one rule in the loop — a history
must advance exactly once per step — and warned when it did not. It has been
removed. It claimed the authority of an invariant for a heuristic about usage;
it was already known to be wrong for a swarm that sub-cycles; it sat at the
wrong layer, since what knows a step was taken twice is the dataflow rather
than the container; and it could only ever see one step, whereas the check that
matters most cannot be seen from one — #423's signature is a growth rate of
~10% per cycle.
So the line now falls between structural checks — is the transcript
well-formed? a step cannot nest; a rewind cannot reach a step that kept no
snapshot — which stay in the loop and raise; and findings — does what was
recorded look wrong? — which belong to a pass over a finished transcript. That
pass can look across steps, can be re-run on an old transcript when a new
pathology is learned, and never has to decide mid-run whether something was
deliberate.
The recording is unchanged: both shifts of a doubled step are in the
transcript, in order. What went is the judgement.
Notes for review
The guards all have negative controls, which matter more than the positive
cases: an ordinary one-solve-per-step loop is asserted silent; an untouched
free-surface manager solves; an ordinary constant still ramps; and a parameter
whose value is an expression over mesh variables still tracks when the FIELD
changes, since symbolic sharing is correct and only re-assignment drifts.
The free-surface guard compares against what copying the parameter today
would produce, applying the same velocity rebinding
_copy_constitutive_modelapplies. A nonlinear rheology is deliberately rebound onto each derived
solver's own unknowns, so those expressions are meant to differ textually — the
first version of the guard failed
test_1070's nonlinear-viscosity case forexactly that reason.
test_1074is added toscripts/test.shbesidetest_1072; thetest_107*group is not otherwise batched and it would never have run.
Related: #708 shares a trigger with the constants defect — a zero at compile
time — but not the mechanism. There the victim is a raw zero history matrix, so
no slot exists to lose and the term is arithmetically absent. That PR's fix,
populating before compiling, is the right one for it.
Added since this description was first written
solver.describe()returns the residual templates with their symbols and docstrings, named expressions expanded recursively into the constitutive model, the boundary conditions — including rotated free-slip, which is applied outside the solver — and the terms the solver was given.view()renders it and the transcript serialises it, so a note and a run cannot quote different equations. A solver adopts the contract by declaring a roster,_solver_terms = (("f", "volumetric source term"),), andtest_0016pins the set that has not, in both directions.solveevent carries the converged reason, nonlinear and Krylov counts, |F| and the reduction, and whether a fieldsplit block hit its cap — a converged solve on a capped velocity block is neither a success nor a failure, and the record says which. Warnings raised inside the step are recorded with their origin and rank; the shim delegates to whatever hook was installed. The text log's outcome column readsok/DIVERGED/ABANDONEDand names the failing solve beneath the step.(position, outcome), so a step whose block gave up cannot hide inside a collapsed run of clean ones.transcript_tableandtranscript_figure; the musical vocabulary is gone from everything user-facing and lives in the design note as derivation. The note is nowrun-plan-and-transcript.md.DFDt, the Crank–Nicolson old flux) is labelledSemiLagrangian(F[T])besideSemiLagrangian(T), with the tracked expression on the event and the velocity levels the trace-back read — the tie from one step to the last../transcripts/<stamp>-<script>/by default, with the launch script kept, both formats, alatestsymlink and arun_endterminator;UW_TRANSCRIPToverrides.solveevents whenever_update_constantsran for a parameter change or the continuation toggle — only thesolve()bodies record now; the warnings shim restores the previous hook only if the shim is still installed and takes a four-argument hook; identical warnings are deduplicated with a count in the log; a label's whitespace is collapsed. The once-per-location filter, nestedcatch_warnings(record=True)and the rank-0 file view are documented limits of the warnings machinery.Known gaps, not addressed here
A snapshot still cannot restore across a mesh deform or adapt, so
model.rewind()cannot reach a step on a moving or adapting mesh. The runwarns once and keeps recording. That is the mesh-rebuild-on-restore path
already planned for v1.2.
FreeSurfaceis not a snapshot state-bearer:_h_infand_conserve_targetare not captured, and
_pending_v_mesh_dispis not a field ofDDtSemiLagrangianState. Filed rather than fixed.uw.pprint's defaultclean_display=Truerewrites the string it is given —three passes of a brace-stripping regex plus
\s+collapsed to a single space— so the project's recommended rank-safe print destroys indentation, column
alignment, and any literal braces. The annulus example works around it with
clean_display=False. Narrowing the cleaner is not free, since the documenteduse is
uw.pprint(f"Expression: {expr}")and so it has to run on f-stringstoo. Filed, not fixed here.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V