Skip to content

The timestep as a transaction: model.step, a run transcript you can watch and publish, and eight silent defects - #716

Open
lmoresi wants to merge 21 commits into
developmentfrom
docs/timestepping-pattern
Open

The timestep as a transaction: model.step, a run transcript you can watch and publish, and eight silent defects#716
lmoresi wants to merge 21 commits into
developmentfrom
docs/timestepping-pattern

Conversation

@lmoresi

@lmoresi lmoresi commented Sep 10, 2026

Copy link
Copy Markdown
Member

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 = uw.get_default_model()
model.set_reference_quantities(...)      # before the mesh; units are a choice
                                         # you can only make here
mesh = uw.meshing.UnstructuredSimplexBox(...)

model.tracker.time = uw.quantity(0.0, "Myr")
model.record_every = 1

while model.tracker.time < end_time:
    dt = adv_diff.estimate_dt()
    with model.step(dt):
        adv_diff.solve(timestep=dt)
        stokes.solve(zero_init_guess=False)
<step 0 'convect' dt=0.01 solve:SNES_AdvectionDiffusion(T) -> history_shift:EulerianSUPG(T) -> solve:SNES_Stokes(V)>

model.step(dt) owns a time interval. The clock reads the END of that interval
for the whole block, because an implicit residual is centred at t+dt and a
driven 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 it
solves. 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_every asks each step to keep the state it started from, and
model.rewind() undoes a step: fields, history and clock together. 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 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

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 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.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: 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 stamped
directory 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 -f follows a running job.

# underworld3 run transcript · 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:SNES_AdvectionDiffusion_Composed(T) > history_shift:EulerianSUPG(T) > solve:SNES_Stokes(v)
      4         30.5663         29.0517      0.09  ABANDONED  [too big] solve:SNES_AdvectionDiffusion_Composed(T) > history_shift:EulerianSUPG(T) > solve:SNES_Stokes(v)
  -- 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:SNES_AdvectionDiffusion_Composed(T) > history_shift:EulerianSUPG(T) > solve:SNES_Stokes(v)

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. 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/s is how long the block took; a step that suddenly takes ten times as
long is the first sign of a solver in trouble.

A .jsonl suffix (or transcript_format = "jsonl") writes the same record as one
JSON object per line, read back with uw.read_transcript. 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, 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.

uw.transcript_diagram(model, out="figures/run.pdf")     # or a .jsonl log
uw.transcript_diagram(model, out="figures/run.svg")     # same figure, SVG
uw.transcript_flowchart(model)                          # Mermaid, for docs

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. .svg writes the same layout as
one continuous page.

The letter is the layout. Each distinct operator sequence gets one, defined
once at the foot:

step   t/Myr   dt/Myr   seq   dt
  11   9.856    1.920    A    ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
  12     145    135.2    A    ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄  abandoned
  11   9.856    1.920    A    ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
  12   12.56    2.704    B    ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇

A   AdvectionDiffusion(T)  >  shift EulerianSUPG(T)  >  Stokes(v)       14 steps
B   AdvectionDiffusion(T)  >  shift EulerianSUPG(T)  >  Stokes(v)  >
    AdvectionDiffusion(T)  >  shift EulerianSUPG(T)  >  Stokes(v)        1 step

A column of A with a single B in it says at a glance that one step did
something 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
dt axis goes
logarithmic 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 same
model many times — an inversion, a parameter sweep, a restart — otherwise gets
the concatenation of every run the process has done, and rewind() walks back
into 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. record is kept as a verb: a step records what it
did; the thing it produces is the transcript.

docs/developer/design/run-score-and-transcript.md is the design note behind
that — 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 runnable
cases 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 the
nondimensionalisation rather than being typed in; the script prints
Ra = 2.585e+06 as a check. Rotated free-slip on the curved boundaries, and a
varying estimate_dt() that goes straight into model.step(dt). After the
loop 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 own
operator sequence.

Compare ../advanced/Ex_Convection_Cylinder.py, which solves the same physics
with 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.transcript backwards — 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.t was silently zero inside every solve (#410). It was bound to
PETSc's petsc_t, which the high-level solve() wrappers never set, and
solve(time=...) was accepted and ignored. A time-dependent boundary condition
written as sin(omega * mesh.t) — the usage mesh.t's own docstring
advertises — was identically zero, and nothing warned. Now a live-rampable
constants[] atom carrying model.tracker.time, repacked in
_update_constants, which is the single choke point every solver already
passes 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, was
written as __skipped, and was absent after load_state. With reference
quantities set, estimate_dt() is dimensional and so is the natural clock — so
the 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 is
the "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) + 1 is the NUMBER 2 while m is zero, so the whole
diffusivity banks as one constant and the collector stops there. Ramp m and
it depends on T again, the slot can no longer be reduced, and the solve
received a zero diffusivity: measured 2.0, then 0.0, then 0.0, with a
DIVERGED_LINEAR_SOLVE and 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. held and
consistent are 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 HELD
solve — so the surface relaxed toward an equilibrium computed with stale
physics while the free solve used the new. solve() now refuses and names what
drifted.

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.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 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.evaluate returning the value at one corner
for every sample point, because every sample point is now outside the domain.
model.rewind() goes straight through that path, which is how it surfaced. The
swarm 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 — except
that solve_rotated_freeslip 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 record 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 promotes
it to a 0-d array, uw.dimensionalise maps an array to a UnitAwareArray, and
that drops its units under arithmetic. The guard for exactly this already
existed in _dimensionalise_dt but sat on the no-units branch, so
Stokes.estimate_dt (numpy scalar) was fine and AdvDiffusion.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_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 the guard failed test_1070's nonlinear-viscosity case for
exactly that reason.

test_1074 is added to scripts/test.sh beside test_1072; the test_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 run
warns once and keeps recording. That is the mesh-rebuild-on-restore path
already planned for v1.2.

FreeSurface is not a snapshot state-bearer: _h_inf and _conserve_target
are not captured, and _pending_v_mesh_disp is not a field of
DDtSemiLagrangianState. Filed rather than fixed.

uw.pprint's default clean_display=True rewrites 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 documented
use is uw.pprint(f"Expression: {expr}") and so it has to run on f-strings
too. Filed, not fixed here.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V

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
Copilot AI lite review requested due to automatic review settings September 10, 2026 04:32

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 ModelStep journaling + model.step(dt) context manager, plus optional record_every snapshots and model.rewind() support.
  • Make mesh.t a live rampable constant synced from model.tracker.time before 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.

Comment on lines +591 to +594
def note(name, what, mine, theirs):
if str(mine) != str(theirs):
drift.append(f"{what} — free: {str(mine)[:60]} | {name}: {str(theirs)[:60]}")

Comment on lines +526 to +540
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
Comment thread src/underworld3/model.py
Comment on lines +777 to +780
``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.
lmoresi and others added 2 commits September 10, 2026 09:45
…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
@lmoresi lmoresi changed the title The timestep as a transaction: model.step, a run record, and four silent defects The timestep as a transaction: model.step, a run record, two worked cases, and seven silent defects Sep 10, 2026
…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
@lmoresi lmoresi changed the title The timestep as a transaction: model.step, a run record, two worked cases, and seven silent defects The timestep as a transaction: model.step, a watchable run log, two worked cases, and eight silent defects Sep 10, 2026
…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
@lmoresi lmoresi changed the title The timestep as a transaction: model.step, a watchable run log, two worked cases, and eight silent defects The timestep as a transaction: model.step, a watchable run log, a publishable figure, and eight silent defects Sep 10, 2026
lmoresi and others added 5 commits September 10, 2026 16:05
…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
@lmoresi lmoresi changed the title The timestep as a transaction: model.step, a watchable run log, a publishable figure, and eight silent defects The timestep as a transaction: model.step, a run transcript you can watch and publish, and eight silent defects Sep 11, 2026
lmoresi and others added 2 commits September 11, 2026 11:42
`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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants