Capture expression contents in the snapshot, so parameters rewind with the fields - #775
Merged
Merged
Conversation
…d with the fields (#774) snapshot() captured each mesh's coordinates and mesh-variable DOFs, each swarm's particles, and every registered state-bearer. A uw.expression is none of those, so it was not captured at all. A restore therefore put the FIELDS back and left every parameter wherever the run had since moved it, with nothing raised. A replayed step then solved a different problem from the one the transcript recorded. Ramping a parameter between steps - kappa.sym = 7.0 - is the ordinary way to write such a run, so this was not an exotic path: after rewind to step 1: kappa=7.0 (should be 1.0) T=1.0 (correct) Every live expression's contents are now captured alongside the fields and put back on restore. Two details that decide whether this is correct: * stored by REFERENCE, not deep-copied. _sym is a sympy object and sympy objects are immutable, so a later `sym =` assignment REPLACES rather than mutates and the reference cannot go stale. Deep-copying would be actively wrong: an expression's _sym can carry mesh-variable symbols, and cloning that graph detaches the restored parameter from the live mesh. A test asserts the restored value keeps its coordinate symbols. * missing on either side is quiet, not fatal. Captured-but-collected has nothing to write to; alive-but-not-captured was created after the snapshot, so the snapshot has no opinion about it. That differs from the state-bearer rule directly above, which raises - a missing state-bearer means the snapshot came from a different Model, whereas expressions are made and dropped freely during a normal run. Scope is wider than rewind(): checkpoint/snapshot.py is on development and only rewind() is confined to the timestepping branch, so save_state()/load_state() users have the same gap today. It also reaches the discrete adjoint, whose driver restores a step's snapshot and replays the solve - replaying at the wrong parameter values gives a gradient for a problem the run never solved, and a Taylor test would not catch it because both sides move together. Verified by negative control: with the restore disabled, the two contract tests fail and the other 25 pass. Noted, not fixed: expressions are already held STRONGLY by a name-keyed cache elsewhere in UW3, so dropping the last user reference does not collect one. The capture registry is a WeakSet and so adds no lifetime, but it is not what bounds the set today - the docstring says so rather than claiming a weakness the code does not currently have. Worth its own issue. tests/test_0007_snapshot_inmemory.py 27 passed; tests/test_00[0-4]*py 145 passed. Underworld development team with AI support from Claude Code
The first version added a WeakSet of every UWexpression. That was a second registry for a job the first one already does, and it described expression lifetime wrongly in the bargain. UWexpression._expr_names IS the definition of a UW expression: a container with identity by NAME, looked up rather than rebuilt, so uw.expression(r"\eta", ...) reaches the same object twice and a formula written early keeps seeing later edits to its contents. The strong hold is the point, not a leak. That registry is exactly the set whose contents a snapshot must capture - the parameters of the run - so capture now reads it directly. _ephemeral_expr_names is deliberately excluded. Those are the _unique_name_generation=True expressions built for derivative lowering and template substitution: contents derived, held weakly, keyed (name, uw_id), and rebuilt from the persistent ones. Restoring them would write over a value the machinery is about to recompute. Two tests follow the corrected model: capture reads the container registry and skips the ephemerals, and a name reused twice is ONE container captured ONCE - which is the identity rule the design rests on. tests/test_0007_snapshot_inmemory.py 28 passed; tests/test_00[0-4]*py 146 passed. Underworld development team with AI support from Claude Code
lmoresi
force-pushed
the
bugfix/snapshot-captures-expressions
branch
from
September 22, 2026 20:00
c64d83c to
30b2130
Compare
lmoresi
added a commit
that referenced
this pull request
Sep 23, 2026
…nscripts #697 and #775 landed while this branch was open. expressions.py and test_0007 conflicted because this branch carried a SUPERSEDED draft of the expression-capture fix: it was developed in this worktree (rewind lives here, so the round trip could be exercised end to end) and then ported to a branch off development, where review replaced the parallel WeakSet with a read of UWexpression._expr_names - the registry that already defines the container. The draft was never taken back out here, and `git add -A` on the Charter commit swept it in. Resolved by taking development's side wholesale: the reviewed version is the one that merged as #775. The same `git add -A` also committed run OUTPUT - two transcripts/ directories written by the repro script while the bug was being reproduced, with their launch.json and transcript.jsonl. Removed, and .gitignore now covers transcripts/ anywhere rather than only under docs/examples: a run writes its transcript beside itself, so ANY directory a script is run from collects one. tests/test_00[0-4]*py 254 passed; deprecated-pattern scan clean (87 allowlisted, down from 88 - the removed scratch file took one with it). Underworld development team with AI support from Claude Code
lmoresi
added a commit
that referenced
this pull request
Sep 24, 2026
…s, and the expression snapshot 64 commits of development — #697, #716, #753 and #775 among them. Two conflicts, both the same shape as #716's, because this branch forked from the timestepping branch before that merge happened. swarm.py — kept BOTH sides. Two unrelated additions land at the same point: this branch's _adjoint_support (the particle-set rule: an advection is adjointable exactly when the count is unchanged across it) and development's _characteristics_for. Neither supersedes the other. CLAUDE.md — took development's trimmed version. This branch still carried the pre-#725 file, so taking its side wholesale would have reverted a 22.9 KB -> 8.3 KB cut. The one ruling it genuinely adds — the consistent_jacobian tangent policy, and that the rotated constraint is transparent to it — is carried over, rewritten in the trimmed file's voice and attached to the free-slip ruling it belongs with. Also annotated one except-pass the Charter scan flagged in adjoint.py: the _sync_lvec_to_gvec call is an optimisation, not the write. The values are already in out_var.vec; a variable class without that method syncs on demand instead, which is correct and merely later. Caught AttributeError specifically, so a failure inside the sync still propagates. Adjoint suite (test_0018-0026) 38 passed, including the nonlinear-continuation gradient that CI had been failing on. Core band tests/test_00[0-4]*py 292 passed. Deprecated-pattern scan clean. Underworld development team with AI support from Claude Code
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #774.
snapshot()captured each registered mesh's coordinates and mesh-variable DOFs, each swarm's particles, and every registered state-bearer. Auw.expressionis none of those, so it was not captured at all.A restore put the fields back and left every parameter wherever the run had since moved it. Nothing raised. A replayed step solved a different problem from the one the transcript recorded.
Ramping a parameter between steps (
kappa.sym = 7.0) is the ordinary way to write such a run, so this is not an exotic path.Scope is wider than
rewind()checkpoint/snapshot.pyis ondevelopment; onlyrewind()is confined to the timestepping branch. Anyone callingmodel.save_state()/model.load_state()today has the same gap — the field half of the state round-trips and the parameter half does not.It also reaches the discrete adjoint, whose driver restores a step's snapshot and replays the solve. Replaying at the wrong parameter values gives a gradient for a problem the run never solved — and a Taylor test would not catch it, because both sides move together.
Two details that decide whether this is correct
Stored by reference, not deep-copied.
_symis a sympy object and sympy objects are immutable, so a latersym =assignment replaces rather than mutates, and the reference cannot go stale. Deep-copying would be actively wrong here: an expression's_symcan carry mesh-variable symbols, and cloning that graph detaches the restored parameter from the live mesh. A test asserts the restored value keeps its coordinate symbols.Missing on either side is quiet, not fatal. Captured-but-collected has nothing to write to; alive-but-not-captured was created after the snapshot, so the snapshot has no opinion about it. That differs from the state-bearer rule immediately above, which raises — a missing state-bearer means the snapshot came from a different
Model, whereas expressions are made and dropped freely during a normal run.Tests
Four added to
tests/test_0007_snapshot_inmemory.py:Negative control: with the restore disabled, the two contract tests fail and the other 25 pass — so they discriminate rather than passing by construction.
tests/test_0007_snapshot_inmemory.py27 passed;tests/test_00[0-4]*py145 passed.Capture reads the existing container registry
An earlier revision of this PR added a
WeakSetof everyUWexpression, and described the strong hold on expressions as a leak. Both were wrong, and the second commit corrects them.UWexpression._expr_namesis the definition of a UW expression: a container with identity by name, looked up rather than rebuilt, souw.expression(r"\eta", ...)reaches the same object twice and a formula written early keeps seeing later edits to its contents. The strong hold is the design. That registry is exactly the set whose contents a snapshot must capture, so capture now reads it directly instead of maintaining a parallel one._ephemeral_expr_namesis deliberately excluded — the_unique_name_generation=Trueexpressions built for derivative lowering and template substitution. Their contents are derived, they are held weakly and keyed(name, uw_id), and they are rebuilt from the persistent ones; restoring them would write over a value the machinery is about to recompute.Underworld development team with AI support from Claude Code
Scope note: the expression re-declaration rule that was briefly on this branch has been split out to #780. This PR is now only the snapshot fix: capture and restore expression contents. #780 stacks on it.