The timestep as a transaction: model.step, a run transcript you can watch and publish, and eight silent defects - #716
The timestep as a transaction: model.step, a run transcript you can watch and publish, and eight silent defects#716lmoresi wants to merge 21 commits into
Conversation
Every time-dependent script needs a clock, and there is no documented pattern for where to keep it. Scripts keep it in a local variable, which is silently wrong the moment anyone snapshots. model.tracker is captured by save_state and reverted by load_state; a loose Python variable is not. So a script that backsteps gets its fields restored and its time left in the future, with nothing raised. Measured on a five-step run: after restoring a snapshot taken at t = 1140, a loose t still read 2280 while the fields were correctly back at 1140. Adds a "The Timestepping Pattern" section to the script guide: the loop shape, the backstepping recipe, why the snapshot goes BEFORE the operator (a DDt shifts its history post-solve), and the note that restoring is bit-exact where re-running is not. Also records a live gap. mesh.t is a separate symbolic atom bound to PETSc's petsc_t, which the high-level solve() wrappers never set, so an expression containing it evaluates to ZERO inside a solve and solve(time=...) is accepted and ignored. A boundary condition written as sin(omega * mesh.t) — the usage mesh.t's own docstring advertises — is identically zero and nothing warns. Added as an xfail alongside the tracker tests, with a constant-source control so the assertion cannot pass for the wrong reason; it starts passing the day mesh.t resolves to the model clock, per the #410 ruling. Underworld development team with AI support from Claude Code
A default model is created for you if you never ask for one, so it is easy to write a whole script without noticing it exists — and reference quantities must precede mesh creation, so by the time the omission is noticed it is too late to fix without rebuilding the mesh. Declaring the model on the first line puts the units decision at the only point where it can still be made, and estimate_dt() then returns a dimensional quantity so the clock carries units with no extra work. Neither the model nor the units are enforced today; both arrived after much of the surrounding code. The pattern is written as though they were, since that is where this has to end up. Records the gap that stands in the way. An in-memory snapshot round-trips a units-carrying tracker entry correctly, but the on-disk snapshot does not: a pint Quantity falls through disk_snapshot's "unserialisable type" branch, is recorded as <name>__skipped, and is simply ABSENT after load_state, so reading it afterwards raises. Nothing warns at save time; plain floats, ints and numpy arrays are unaffected. So the very entry the pattern asks for is the one a restart loses. Added as an xfail with a plain-float control, so it cannot pass for the wrong reason and cannot be mistaken for a broken tracker or a broken file. A Quantity is (magnitude, units) and is trivially serialisable. Underworld development team with AI support from Claude Code
_serialise_field handled None, scalars, strings, numpy arrays, dicts and JSON-able lists; a dimensional value fell through to the "unserialisable type" branch, was recorded as <name>__skipped, and was simply ABSENT after load_state, so reading it raised. Nothing warned at save time. This bit the timestepping pattern directly. With reference quantities set, estimate_dt() returns a dimensional quantity, so the natural clock on model.tracker is dimensional — and that was exactly the entry a restart lost, while plain floats beside it survived. A quantity is a magnitude and a unit, so it stores as `<name>__magnitude` + `<name>__units`. The magnitude goes back through the ordinary dispatch, so a scalar lands in an attribute and an array in a dataset and both round-trip. Detection is duck-typed on magnitude/units because uw.quantity returns a UWQuantity, which is NOT a pint.Quantity subclass, so an isinstance test against either would miss the other. _group_to_dict re-pairs the two halves in a post-pass, since the tracker's managed entries arrive as a dict subgroup and the halves come from different loops. The DDt state is unaffected: it stores dt and dt_history non-dimensionally as plain floats, so the list-of-quantities case does not arise there. Tests: the xfail is now a passing test, plus an array-magnitude case. 140 passed in the test_00[0-4]* batch. Underworld development team with AI support from Claude Code
mesh.t was a symbolic atom bound to PETSc's petsc_t, which the high-level solve() wrappers never set. An expression containing it evaluated to ZERO inside every solve, silently, and solve(time=...) was accepted and ignored — so a time-dependent boundary condition, the usage mesh.t's own docstring advertises, was identically zero and nothing warned. Implements the #410 ruling rather than repairing the PETSc plumbing, which that ruling records as tried and failed. mesh.t is now a live-rampable constants[] atom carrying the clock owned by the orchestration model, and Mesh._sync_time_from_model repacks it from model.tracker.time. The hook is the solver's _update_constants, which is the single choke point already called before every solve, so no kernel is recompiled per timestep and no solver needed its own hook. A dimensional clock is non-dimensionalised on the way in. The prerequisite the ruling names — constants baked as C literals, #302 — was measured and is satisfied: an atom now ramps in every position a clock occupies, bare, linear, inside a transcendental, and in exponent position. The planning file's separate "exponent position does not ramp" report does not reproduce; the likely explanation is that building the expression with the atom at zero lets sympy fold the power away, so the atom is compiled out and can never ramp afterwards. That is a property of the starting value, not of exponent position. Also fixes a defect this exposed. The source setter's .value/.units duck-test for a dimensional quantity matches a UWexpression too, which is a symbolic atom rather than a plain quantity, so `poisson.f = <atom>` baked a rampable constant to a literal at assignment. UWQuantity and pint Quantity are not sympy objects and UWexpression is, which separates them. Tests: the two xfails are now passing tests, covering a time-dependent source and a time-dependent boundary condition (assembled through a different residual path), each asserting the solution TRACKS the clock rather than merely being non-zero; plus the bare-atom regression. 145 passed in test_00[0-4]*/test_0103*, 211 in test_01*/test_02*. Underworld development team with AI support from Claude Code
The first piece of the taping strategy, and the one that pays for itself
without any of the rest.
A timestep had no boundary in the library. Three exist at lower levels — a
characteristic trace bracketing two history managers inside one solver, the
DDt pre/post pair inside each solver, the free surface's own three-call
sequence — but none of them is the step a user means, so nothing could say
what a step did or whether it finished.
`with model.step(dt):` supplies that boundary and three guarantees:
* the clock reads the END of the interval for the whole block. An implicit
scheme centres its residual at t+dt, so a time-dependent coefficient — a
driven boundary above all — must be evaluated there. Committing only on
exit would evaluate every implicit coefficient a step late, which is a
first-order error that looks right and converges.
* the advance commits on clean exit and only then. A step that raises, or
one abandoned on a Courant check, leaves model.tracker untouched. Two
clock reads inside one step previously saw different times if anything
advanced in between; measured.
* everything the block did is recorded in model.journal, in order, named by
what it solves: "SNES_AdvectionDiffusion(T) -> SNES_Stokes(V)", not
"Solver_14_ -> Solver_8_".
That record is the point. It answers what a run actually did, without the
script being instrumented — the question you want to ask of someone else's
model, or your own later. It is also the ordered half of an adjoint tape,
which snapshots already supply the state half of.
Solvers report through _update_constants, the one place every solver passes
before solving, so one hook records them all in order. Recording is a no-op
outside a step block, so nothing is required of a script that does not use
one, and steps do not nest.
Deliberately NOT in this change: snapshot policy on the step, and the
per-history-manager invariant (each DDt should shift exactly once per step —
today a step that solves twice takes the physical step twice, invisibly).
Both want this boundary to exist first.
149 passed in test_00[0-4]*.
Underworld development team with AI support from Claude Code
…ck zero An expression gets a constants[] slot because it reduced to a single number when the kernel was COMPILED. If a nested atom is later ramped so that it depends on position again, the compiled kernel still reads a scalar there — and _pack_constants silently packed 0.0 into it. That is the failure behind the "rampable constant in exponent position does not ramp" report, and the mechanism is not what the report assumed. The atom is NOT compiled out. `(1 + T**2)**(-m) + 1` is the NUMBER 2 while m is zero, so the whole diffusivity banks as ONE constant and the collector stops there without recursing to m. Ramp m and the expression depends on T again; the slot can no longer be reduced, and the solve receives a zero diffusivity. Measured: packed 2.0 at m=0, then 0.0 at m=0.5 and m=1.0, with a DIVERGED_LINEAR_SOLVE and a zero answer, and nothing said why. Now it raises, naming the slot, showing its current content, and giving the two lines that force a rebuild. Verified that the prescribed recovery works and that the atom then ramps. Note for anyone reading the collector: a zero-valued constant is NOT the problem, and constants are not folded away. `_reveal_constants`, which the cache key uses, keeps `3*c` and `x**(-c)` symbolic at c = 0.0, and a constant compiled while holding zero ramps correctly through the source-term path in every position tried. The hazard is the ENCLOSING expression collapsing to a number, not the atom vanishing. Distinct from #708, which shares the trigger 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. Populating before compiling is the right fix there. 326 passed in test_00[0-4]*/test_01*. Underworld development team with AI support from Claude Code
Increment two of the taping strategy. The step already recorded WHAT ran; it
can now keep the state that step started FROM, which is what turns a journal
into a tape.
model.tape_every = 1
with model.step(dt):
adv_diff.solve(timestep=dt)
stokes.solve(zero_init_guess=False)
model.rewind() # fields, history and clock, together
The snapshot is taken before the operators, which is the only correct point:
a DDt shifts its history in its post-solve hook, so a snapshot taken after a
solve holds the shifted history rather than the step's input.
Verified that replaying a rewound 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 the
whole argument for playback over re-running: a step that misbehaved can
actually be looked at twice.
The clock comes back with the fields because it lives on the tracker and the
tracker is captured with everything else. That was the point of putting it
there.
Retention is a policy: tape_every chooses how often, tape_limit how many to
keep. Old steps lose their snapshot and KEEP their journal record, so the
account of what happened outlives the state it happened to. Snapshots run
about 13 bytes per primary dof per step, so on a large mesh the memory, not
the time, is the constraint — taping costs 0.5 ms against a multi-second step.
On a mesh that deforms or adapts the snapshot cannot be taken at all yet, so
the run warns once, keeps journalling, and says rewind will not reach those
steps. That is the mesh-rebuild-on-restore gap, not this change's to fix.
Also documents the .sym pitfall the session turned up: to change a value set
.sym, never rebind the name. .value and .data are derived read-only views, and
rebinding leaves every expression still pointing at the old atom with its old
value, silently.
154 passed in test_00[0-4]*.
Underworld development team with AI support from Claude Code
"Taping" borrows a word from automatic differentiation for something that
serves a broader purpose here — the ordered account of what a run did is
useful to anyone asking whether a model is doing what its author says, with
or without an adjoint. "Record" says that without the borrowed connotation.
model.tape_every -> model.record_every
model.tape_limit -> model.record_limit
model.tape -> model.restore_points
`model.journal` and `model.rewind()` are unchanged; `step.restorable` still
reports whether a step kept the state it started from. Naming only, no
behaviour change.
154 passed in test_00[0-4]*.
Underworld development team with AI support from Claude Code
Increment three: the record now checks the step, not just describes it.
A history manager must advance exactly once per model step. Call a solver
twice inside one step — a corrector, a Picard iteration on a coupled system,
a retry — and its history shifts twice, so the physical step is taken twice.
Measured earlier: the field advanced a further 3.6e-2 while
n_solves_completed (capped at order) and dt_history looked identical to a
single step. Nothing in the library could see it.
Each flavour now notes its shift where the shift actually happens, on the
line that writes dt into the history, so the record says which history moved
rather than only that a solver ran:
<step 0 dt=0.01 solve:SNES_AdvectionDiffusion(T)
-> history_shift:EulerianSUPG(T)
-> solve:SNES_Stokes(V)>
The history is named for the field it TRACKS, not for its psi_star slot,
whose generated name tells a reader nothing.
Six flavours, six one-line calls, at an anchor that IS the shift. A no-op
outside a model.step block, so nothing is required of a script that does not
open one, and the ordinary one-solve-per-step loop is silent — asserted, so
the guard cannot start crying wolf unnoticed.
372 passed across test_00[0-4]*, test_01*, test_02*.
Underworld development team with AI support from Claude Code
FreeSurface builds `held` and `consistent` as separate Stokes solvers and copies the free solve's configuration into them ONCE, when the manager is built. Change the free solve afterwards and they keep the old values, with no warning. Measured: viscosity 1000 on the free solve against 1 on both derived ones, body force -5 against -1, tolerance 1e-11 against 1e-6. That is a wrong answer rather than a crash. `h_inf` — the equilibrium the surface relaxes toward — is recovered from the HELD solve's constraint reaction, so the surface would relax toward an equilibrium computed with stale rheology and stale buoyancy while the free solve you are watching uses the new ones. `solve()` now checks first and refuses, naming what drifted and saying that the Stokes solver must be configured before the manager is built. The comparison is against what copying the parameter TODAY would produce, applying the same velocity rebinding `_copy_constitutive_model` applies. 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 this guard failed the existing nonlinear-viscosity test for exactly that reason, which is what the test is for. `held_bg` is exempt: it exists to carry a different body force, and its rheology is covered through `held`. So is `held`'s body force when a driving_buoyancy was supplied, since that is deliberately not the free solve's. Two negative controls in the tests: an untouched manager solves, and a parameter whose value is an expression over mesh variables still tracks when the FIELD changes — symbolic sharing is correct, only re-assignment drifts. test_1074 added to scripts/test.sh beside test_1072; the test_107* group is not otherwise batched and would never have run. Underworld development team with AI support from Claude Code
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. |
…ew journal Two things found by rewriting a real adjoint driver on top of model.step. A snapshot round trip multiplied the mesh coordinates by the length scale. `mesh.X.coords` is the unit-aware view and returns metres once a model declares a length scale; the DM coordinate vector `_deform_mesh` writes back into holds model units. Capture took the first and restore wrote the second, so a 500 km box came back 250,000,000 km across. Nothing raised: shapes matched and every field was restored correctly, so only the geometry was wrong. The visible symptom is that `uw.function.evaluate` starts returning the value at one corner for every sample point, because every sample point is now outside the domain — and it compounds on each restore. `model.rewind()` goes straight through that path, which is how it surfaced. The swarm path is unaffected: it captures and restores the raw `DMSwarmPIC_coor` field, not the unit-aware view. `model.clear_journal()` is new. A driver that runs the same model many times — an inversion, a parameter sweep, a restart — needs each run to have its own account. Without it the journal is the concatenation of every run the process has done, and `rewind()` walks back into the previous one. tests/test_0012_snapshot_units_coords.py covers the coordinate round trip, the compounding, the evaluate symptom, and the rewind path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V
A second worked case for the timestepping pattern, in a different geometry from the sinker adjoint: Boussinesq convection in a 2D annulus, with rotated free-slip on the curved boundaries and a varying estimate_dt(). The buoyancy is written as a force in the units it has, so the Rayleigh number falls out of the nondimensionalisation instead of being typed in. After the loop it demonstrates, in order, the four things the record buys: the journal, a rejected step that leaves the clock where it was, a bit-exact replay, and the invariant catching a step that was taken twice. Writing it found two ways the record did not match what happened. The journal counted one operator as two. The hook lives in _update_constants, which every solver passes on its way to a solve — except that the rotated free-slip loop pushes constants a second time for its own assembly, after the public solve() has already announced the solver. So a Stokes solve on a curved boundary recorded twice, in a journal whose whole value is that it says what ran. _update_constants now takes record=False for a setup push. estimate_dt() lost its units under the pattern's own idiom. `dt = fraction * solver.estimate_dt()` came back as a bare float whenever the estimate was a Python float rather than a numpy scalar: np.squeeze promoted it to a 0-d array, and a dimensionalised array is a UnitAwareArray, which drops units under arithmetic. The guard for exactly this already existed in _dimensionalise_dt but sat on the no-units branch. AdvDiffusion's accuracy estimate hit it; Stokes's did not, so the two disagreed. tests/test_0013_step_record_fidelity.py covers both, plus the case the de-duplication must not hide: a solver genuinely called twice in one step is still recorded twice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V
…ecords backtracks
The journal existed only in memory: bounded, and gone with the process. A
record you cannot read while the run is going, or after it died, is half a
record.
`model.journal_file = "output/run.log"` writes one aligned line per step,
appended and flushed as the step closes, so `tail -f` follows a running job:
# underworld3 step log · model 'default' · started 2026-09-10T21:22:40+00:00
# scales: length 2.2e+06 m | time 4.84e+18 s | mass 1.065e+47 kg | temperature 2500 K
# step t/Myr dt/Myr wall/s outcome operators, in order
3 1.51459 0.523655 0.09 ok [convect] solve:...(T) > history_shift:EulerianSUPG(T) > solve:SNES_Stokes(v)
4 30.5663 29.0517 0.09 ABANDONED [too big] solve:...
-- restore from a snapshot; the clock now reads 1.51459 Myr
-- rewind to the start of step 3 (t = 0.990939 Myr); 1 step(s) undone
3 1.51459 0.523655 0.42 ok [replay] solve:...
A true log records the backtracks, so rewind() and a bare load_state() each
write their own line — a log that shows step 3, then step 3 again with nothing
in between, is not a log of what happened. rewind writes the more specific note
and suppresses the generic one.
Four other things reach the file that the in-memory journal does not keep: an
abandoned step, a step aged out by journal_limit, an invariant complaint (now
also an event on the step, so it survives the terminal the run happened to
have), and everything up to a kill.
ModelStep gains `wall`, the seconds the block took. Not physics, but the number
you want when watching: a step that suddenly takes ten times as long is the
first sign of a solver in trouble.
A `.jsonl` / `.ndjson` / `.json` suffix, or `model.journal_format = "jsonl"`,
writes the same record as one JSON object per line for parsing, read back with
`uw.read_journal`. JSON lines rather than YAML 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_yaml uses it for.
The two formats differ deliberately. Text is a report: one time column in one
unit, named in the header. JSON is a record: every value keeps the units the
run actually held it in.
Also: an abandoned step no longer retains the snapshot it captured. Nothing can
reach it — the record never joins the journal — so it was field-sized memory
pinned until the traceback was collected.
tests/test_0014_journal_file.py, 17 tests. Suite: 1810 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V
…or docs A green-on-black terminal is not where a run belongs in a paper. `uw.journal_diagram` writes the record as a standalone SVG and `uw.journal_flowchart` renders one step's operator flow as Mermaid. Both take a live model, a .jsonl log, or the list read_journal returns. The SVG is written directly: no plotting library, no rasterisation, nothing fetched at render time, and a print-safe palette that separates in greyscale. It shows dt per step in the order things happened, backtracks as arcs over the bars, a wall-clock strip, and abandoned steps hatched. The dt axis switches to log when the range exceeds 20x and says so — 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. The layout decision worth stating: the operator sequence is drawn ONCE when every step shares it, and only the steps that differ are called out. A hundred identical rows tell you nothing; a hundred identical rows and one that differs tell you everything, but only if the identical ones are not in the way. The Mermaid renderer does the same thing with subgraphs. read_journal now also collects the non-step records — the backtracks — and notes where in the sequence each happened, since a rewind means nothing without the step it interrupted and the step it went back to. A text log handed to read_journal is refused with the one line that fixes it: the text format converts the time column to one unit and drops each event's detail, so it is a report and cannot be read back. Also: an invariant flag no longer appears in the text log's operator column. It is a flag on the step, not an operator the step applied, so it belongs beside the outcome. tests/test_0015_journal_report.py, 15 tests, including that nothing is drawn outside the canvas and that a long sequence wraps rather than running off the page. Suite: 1825 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V
…ency SVG is awkward to open on a Mac outside a browser, and the landscape figure was too wide for a document column. Both fixed by turning the layout ninety degrees: time now runs DOWN the page, one row per step, A4 portrait, paginated. `journal_diagram` writes a PDF by default — directly, no plotting library, no rasterisation, nothing installed. Base-14 fonts, Flate-compressed content streams, a real cross-reference table, and pagination that repeats the column captions. `.svg` still works; it is one continuous page instead. Each row is the step index, the clock, dt as a number and as a bar, a wall-clock tick, and ONE LETTER. The letters are the layout: each distinct operator sequence gets one, defined once at the foot of the figure. A column of A with a single B in it says at a glance that one step did something different, where a hundred spelled-out sequences say nothing and hide the one that matters. Backtracks are drawn in the left gutter as an arrow from the step that ended back up to the step it returned to. One layout now feeds two writers through a small op list, so the SVG and the PDF are the same figure rather than two drawings that drift apart. Fixed while turning it: a backtrack was drawn onto whichever page the cursor had reached rather than the page its rows are on, so on a paginated run every arrow landed on the last page. One that reaches back past a page break now says so where it happened instead of drawing a line to a row that is not there. An abandoned bar is also capped short of the wall column, since it is usually the longest on the page — that being why it was abandoned — and has to leave room for the word that says so. tests/test_0015_journal_report.py now 21 tests, including that the PDF's xref offsets actually point at their objects (a table that lies makes an unopenable file), that a long run paginates, and that no plotting library is imported to draw. Suite: 1831 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V
Removed by an over-broad cleanup in the previous commit; they are tracked repository files, not run artefacts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V
…om the invariant The backtrack arrows pointed back into the table from wherever the cursor happened to be, which said a jump had occurred but not what the run then did. They are now the path itself: a dashed arrow UP from the step the run bailed out of to the step whose state it returned to, then a solid arrow DOWN from there to the row that takes that step again, labelled "again". The pair is what makes a repeated step index read as a repeat rather than a typo. Three things had to be fixed for that to be drawable. read_journal resolved a backtrack's target by the FIRST row with the matching step index. After a rewind an index appears more than once, so it must search backwards from where the note fired. A bare load_state names no step at all — it is now located by the clock value the note recorded, and left unresolved (with the arrow saying only where it happened) when no recorded step ended there. Two calls that make the same jump — a load_state and then a rewind to the same place — are one backtrack in the run's story. They are grouped into one arrow, labelled by the last call, since that is the one that set where the run ended up; their two labels were otherwise printing on top of each other. The gutter was too narrow for its own labels, so a label ran off the left edge. Widened, and the PDF writer maps the ellipsis and arrows rather than turning them into '?'. Separately: the invariant's advice was wrong. It said "only the last call should carry the timestep", which does not help — a history advances on every solve, and omitting the timestep reuses the last value rather than skipping the shift. There is no "solve without advancing" switch, so a corrector or a Picard iteration has to save and restore the DDt state between passes. The warning and the guide now say that, with the three lines that do it. The guide also now states what the figure's two columns mean, because the letter column was misread: `seq` is what the step RAN, `ok`/`abandoned` is whether it was KEPT. They are independent — the abandoned step here ran the ordinary sequence and was rejected by a check in the script. tests/test_0014 +3, tests/test_0015 +5, including that a repeated step index resolves to the most recent, that a restore is placed by its clock, and that the PDF contains no replacement characters. Suite: 1839 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V
The hard part of recording a run is not the recording but the view: it has to make a relation visible, and until we knew which relation each addition was a patch on the last. The relation is a value stored by one action in one cycle and picked up by a read in the next — which is where every silent defect found while building this machinery lived, #423 included. Names the two documents a run has: the SCORE, what it is supposed to do, and the TRANSCRIPT, what it actually did including the abandoned bars and the re-takes. Both already exist unnamed — the figure's operator-sequence legend is an inferred score and the rows are the transcript — which makes the motivating question mechanical: 'is the model doing the scientific task you say it is' becomes a diff of one against the other. Borrows the vocabulary that goes with them (bar, beat, part, note, rest, tuplet, tie, tempo) because each term carries a convention worth keeping: rests are compulsory, so silence is distinguishable from not-being-watched; a tuplet is bracketed with its ratio, so sub-cycling is notated as ordinary rather than flagged; a tie is drawn across the barline, which is the cross-cycle relation as notation rather than as two marks to associate. Records what that makes checkable, that reads are derivable symbolically while writes come from hooks already in place, and the three things 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. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V
A transcript is what was actually played, false starts and re-takes included, which is precisely what this object holds — and the word carries its own contrast with the score, so the pair names itself. "Journal" was a placeholder that said only "a log of some kind"; it also collides with the publishing sense in a scientific codebase. model.journal -> model.transcript model.journal_file -> model.transcript_file model.journal_format -> model.transcript_format model.journal_limit -> model.transcript_limit model.clear_journal -> model.clear_transcript uw.read_journal -> uw.read_transcript uw.journal_diagram -> uw.transcript_diagram uw.journal_flowchart -> uw.transcript_flowchart utilities/journal_report.py -> utilities/transcript_report.py the three test modules follow their subjects RECORD IS KEPT AS A VERB. A step records what it did; the thing it produces is the transcript. So `record_every`, `record_limit` and `_record_step_event` are unchanged, and prose says "recorded" where it is the action and "transcript" where it is the object. The one place the two were confused — a warning that said a run "keeps journalling" — now says "keeps recording". The text log's header reads "# underworld3 run transcript" rather than "# underworld3 step log". Done now rather than after the PR lands: the rename is mechanical while nothing depends on it and expensive once something does. Suite: 1839 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V
`ModelStep._check_invariants` asserted "a history must advance exactly once per step" and warned when it did not. Four things were wrong with it. It claimed more authority than it had. A class invariant must hold; this was a heuristic about usage, and one already known to be wrong — a swarm that sub-cycles advances its history legitimately more than once. It was at the wrong layer. What knows a step was taken twice is the dataflow — two writes, one read — not the container the step happens to provide. It could only see one bar, and the check that matters most cannot be seen from one. The free-surface instability in #423 is a history recorded in the old frame and read after the mesh moved, growing ~10% per cycle: its signature IS the growth rate, so no per-step rule could ever catch it. A warning is a poor channel. It fires in the hot loop at the moment the user may be doing something deliberate, and the remedy it offered was wrong the first time it was written. So the line now falls between STRUCTURAL checks — is the transcript well-formed? a step cannot nest; a rewind cannot reach a bar 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 bars, 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: a history shift is still an event, both shifts of a doubled step are still in the transcript, in order. What goes is the judgement. The `invariant` event kind, the warning, and the figure's `!` marker and "Invariant" section go with it; the renderers ignore any such event an older transcript may carry. The annulus example's fourth demonstration becomes "a step taken twice" and reports the two shifts without claiming they are wrong. The guide's "What the transcript checks" becomes "Recording, not judging", and keeps the one piece of practical advice that is still true: a history advances on every solve whether or not the call passed a timestep, so a corrector has to save and restore the DDt state — which is a gap in the library, not a rule the user broke. The design note records the ruling and the reasoning. Suite: 1839 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V
…t that made it
The account is only worth having on the run you did not prepare for. Requiring
`transcript_file` to be set defeated the case it exists for: you turn it on
after the run that went strange, by which point the thing you wanted is gone.
A run that takes a step now lands in
transcripts/2026-09-11T14-32-05-my_model/
my_model.py the script that launched it, verbatim
launch.json argv, interpreter, cwd, version, commit
transcript.log one aligned line per step, flushed
The stamp is the point: the run you want is the one from this morning, and a
fixed filename would have overwritten it. A directory rather than loose files
because a working directory full of logs and script copies invites mass
deletion, which loses the one you needed.
`launch.json` is the honest answer to reproducibility. A programmatic launcher
cannot be made reproducible by fiat, but what was ACTUALLY RUN can be written
down: the command line, the interpreter, the working directory, the package
version, and the commit id with a dirty flag when cwd is under version control.
Only the entry script is copied — anything it imports is not — and the file
says so rather than leaving the reader to discover it.
Three things make the default tolerable, each covered by a test:
- NOTHING IS CREATED UNTIL THE FIRST STEP OPENS. An import, or a script that
only builds a mesh, leaves no trace. A transcript is about a run, and a run
is a sequence of steps.
- OFF UNDER PYTEST. 1800 tests should not each leave a directory.
- SWITCHABLE. `transcript_file = None` turns it off; an explicit path bypasses
the stamped directory entirely and gets no launch record, because a path
the user named is a file they asked for, not a place to put things;
`UW_TRANSCRIPT=off` or `UW_TRANSCRIPT=/scratch/runs` covers a session.
Found while building it, and silent: `PrivateAttr` DEEP-COPIES its default, so
a bare `object()` sentinel for "the user has not said where this goes" came
back as a DIFFERENT object per model and every identity check against it
failed. It is now a copy-stable sentinel class.
Nine tests in test_0014: the stamped directory, the no-step case, two runs not
overwriting each other, the copied script and its manifest, the interactive
case, the explicit path, both off switches, and the pytest suppression.
Suite: 1848 passed.
Co-Authored-By: Claude Opus 5 <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.An adjoint driven from the transcript (a sinking blob under Stokes flow, not
in this repo). The backward pass of a discrete adjoint needs exactly what the
record holds: the state at each step and the order the operators ran in.
Walking
model.transcriptbackwards —load_state(entry.snapshot), replay,transpose-solve — replaced the hand-written
ck = {"B", "V", "P", "dt"}checkpoint dictionary that adjoint carried, and with it the dictionary's
dependence on knowing in advance which arrays the backward pass would want.
Taylor test 1.00000 / 1.00003, the same quality as the hand-taped version. It
is also what surfaced three of the defects below.
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.
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