Skip to content

Reuse the rotated free-slip solver workspace across solves — reworked with a constants-aware key (#417, supersedes #418) - #543

Merged
lmoresi merged 4 commits into
developmentfrom
bugfix/pr418-rework
Aug 13, 2026
Merged

Reuse the rotated free-slip solver workspace across solves — reworked with a constants-aware key (#417, supersedes #418)#543
lmoresi merged 4 commits into
developmentfrom
bugfix/pr418-rework

Conversation

@lmoresi

@lmoresi lmoresi commented Aug 13, 2026

Copy link
Copy Markdown
Member

Reuse the rotated free-slip solver workspace across solves (rework of #418, fixes #417)

This is the landing rework of #418 (gthyagi's "Reuse linear rotated free-slip
solver workspace", bec76bb), which went CONFLICTING after the rotated solve
loop was rewritten (#437, #458, #465/#471, #469, #493, #500, #502, #530/#534),
and which the 2026-07-27 adversarial review held on one structural finding.
gthyagi's commit is cherry-picked with his authorship preserved; the caching is
re-derived at the current seam rather than force-fitting the old text.

What was ported, and where it lives now

The original PR split a linear path from a nonlinear one and cached the linear
path's workspace whole — operator, KSP, and (implicitly) the solution state —
keyed on MeshVariable._state counters, with a matrix probe as safety net.
Since the fork, rotated_bc.solve_rotated_freeslip became ONE manual
Newton/Picard loop for linear and nonlinear models alike, which already reuses
its operator and KSP context between its own iterations. The port extends
exactly that in-loop pattern across solves, split by what each piece
actually depends on:

  • Geometry tier (reused on a matching geometry signature: boundary specs,
    fault registration, DM identity): the rotation Q/Qt, constrained normal
    rows, fault pair blocks, custom-FMG prolongation.
  • Structure tier (always correct — objects reused, values reassembled in
    place): Ahat via ptap-with-result, the Schur pmat via
    createSubMatrix-with-submat, the fieldsplit KSP/PC via a setOperators
    poke. This is the identical operation sequence the Newton loop performs
    between iterations, so it carries the production-validated risk profile.
  • Iteration-0 fast path (skips Jacobian assembly, ptap and PCSetUp): only
    when the operator key proves nothing operator-relevant changed AND the last
    solve on this workspace behaved linearly (converged in ≤ 1 increment — a
    self-measured hint, no up-front nonlinearity probe).

Nothing about the rotated BC's discrete equations changed. All current result
keys (rnorm/rnorm0 for the solve report, the #534 tolerance/report
plumbing, the #502 fault keys) are preserved; rotation_reused and
workspace_reused are added.

Teardown hooks: _reset_rotated_solver_cache() runs from _reset() and from
the _build() full-rebuild branch before the SNES/DM are destroyed
(mesh.deform funnels there). The in-place rewire fast path keeps the cache;
its new kernels are caught by the JIT-key half of the operator signature.
The cache is forfeited outright for direct-LU, prescribed-datum and fault
interface-law solves. Stokes.solve gains the time= pass-through from the
original PR; an explicit time vetoes the fast path (petsc_t bypasses every
counter and constant).

The blind-spot fix (the finding that held the PR)

The review probed that a 2× viscosity ramp via a rampable UWexpression
constant (the #416 contract: value changes bump NO state counter) returned a
bit-identical stale solution flagged workspace_reused=True, and that the
same "unchanged" verdict short-circuited the matrix-probe safety net that was
supposed to catch it. Both halves are fixed, structurally:

  1. The key covers the kernels' constants. The operator signature now
    includes the packed constants[] values the compiled kernels will actually
    assemble with, plus the JIT bundle key. If the manifest or the coefficient
    enumeration cannot be read, the fast path is forfeited — correctness first.
    (This deliberately over-invalidates on RHS-only constant changes;
    RHS-only field changes — the production temperature pattern — still ride
    the fast path.)
  2. The safety net is independent of the key it guards. In the reworked
    loop there is no separate one-shot linear path to poison: the loop measures
    the TRUE residual (fresh kernels, current constants) at every iterate,
    declares convergence only on that, and always reassembles from iteration 1
    on. A wrong fast-path verdict therefore costs one extra increment — it
    cannot return a stale solution. Measured on the naive-key port (negative
    control, house rule): the ramp probe showed reused=True with the WRONG
    flag but the CORRECT halved velocity (4.1e-11 vs a fresh control) — the
    old code returned the stale field bit-identically. The key fix makes the
    flag honest and removes the wasted work.
  3. The verdict is collective. State counters follow rank-local writes, so
    the match verdict is allgathered and must be unanimous before it gates any
    collective PETSc call (a rank-divergent verdict is a hang, not a wrong
    answer).

The matrix probe from the original PR is dropped: its role is subsumed by the
loop's exact-residual verification, which is stronger (it validates the
solution, not two matvecs) and cannot be disabled by its own trigger.

Fault composition (#502)

solve_with_fault drives the same rotated loop, so the decision is explicit
and tested:

  • Frictionless pair blocks cache — the coincident-node pairing and fault
    normals live in Q and are geometry, keyed by the fault registration in
    the geometry signature. Warm repeat reuses the rotation; a cold re-solve
    rides the iteration-0 fast path; both match a fresh-solver control.
  • Interface-law solvers opt out entirely (cache_allowed excludes them):
    the interface tangent is reassembled per iterate at the current slip rates
    and the reaction-fed normal stress is Picard-lagged solver state — neither
    is keyable registration state.

Verification

All serial runs sequential on the pr418-rework worktree (amr-dev env).

Probe Result
Ramp probe (2× UWexpression constant, warm solve, fresh control) fast path correctly defeated (reused=False); error vs fresh control 4.8e-08–4.1e-11 across configs; fail-before validated: naive key reports reused=True
Reuse actually happens 34/35 solves reused on the RHS-change pattern; warm solve 1.000 s vs 2.10 s fresh-solver control = 2.10× speedup (gthyagi measured 1.92× in production); same Q/Ahat/KSP handles asserted
Deform between solves workspace invalidated (rotation_reused=False), post-deform solve matches fresh control < 1e-6 (test)
Memory (#417) 35 solves one process: RSS 347.7 → 351.3 MiB over solves 5–34, +0.12 MiB/solve (was: unbounded growth to OOM); bound used: < 1 MiB/solve with margin
np2 4-solve sequence (fresh, RHS×2, ramp): reuse verdicts rank-identical at every solve [(F,F),(T,T),(T,T),(F,T)]×2 ranks; RHS scaling 9.7e-08, ramp scaling 4.8e-08
test_1018_rotated_freeslip 22 passed (19 existing + reuse + ramp + deform)
Fault set 0845–0848 30 passed (incl. new test_fault_repeat_solve_composes_with_workspace_reuse)
Full gate -m "level_1 and tier_a" (–test_0050) 0 failed

Production evidence (gthyagi)

The original mechanism carries a substantial production validation trail that
this rework inherits and that deserves explicit credit: 310 guarded Zhong A1
steps
(cellsize=1/8, 8 ranks, checkpoints 500→810 in guarded 50-step
segments, UW_MEMPROBE=1, 0.25 s RSS sampling, hard memory/time stops that
never fired), flat warm RSS with periodic collective releases, a continuous
physical trajectory (Vrms 57.83→57.97, Nu_surf 2.694→2.706 over 810 steps),
and a ~1.92× per-step speedup over the pre-fix rate. Since the loop internals
moved under the cache in this rework, gthyagi may wish to re-run an A1
segment on the reworked branch
(a 50-step guarded restart from an existing
checkpoint would do) before merge.

Commits

  1. 3eba6aa4 — Reuse the rotated free-slip solver workspace across solves
    (Repeated rotated free-slip Stokes solves rebuild solver state and grow RSS until OOM #417) — author: Tyagi, cherry-pick of bec76bb re-derived at the
    current seam.
  2. 78338211 — Close the rotated-workspace blind spot: rampable constants
    join the key, the verdict goes collective.
  3. f8c2b726 — Fault contact composes with the rotated workspace cache:
    pair blocks cache, interface laws opt out.

Underworld development team with AI support from Claude Code

gthyagi and others added 3 commits August 13, 2026 11:32
Repeated transient rotated free-slip Stokes solves rebuilt the rotation Q,
the PtAP'd operator, the fieldsplit Schur KSP/PC and the GAMG hierarchy on
every timestep — an allocator high-water problem (RSS growth to OOM, issue
#417) and roughly half the per-step cost of the Zhong A1 production runs.

Cache the workspace on the solver between solves, re-derived from the
original PR #418 (bec76bb) at the seam of the rewritten unified Newton
loop:

- geometry tier (Q/Qt, constrained rows, fault pair blocks, custom-FMG
  prolongation) reused while the boundary/fault registration and DM are
  unchanged;
- structure tier (Ahat, Schur pmat, nullspaces, KSP/PC) reused as objects
  with values refreshed in place — the loop's own between-iteration pattern;
- an iteration-0 fast path skips Jacobian assembly / ptap / PCSetUp entirely
  when the operator coefficient state counters match (RHS-only timesteps);
- the cache is forfeited for direct-LU, prescribed-datum and fault
  interface-law solves, and torn down by _reset() and the _build() full
  rebuild before the SNES/DM are destroyed;
- expose the existing time= argument through the Stokes.solve wrapper; an
  explicit time vetoes the fast path (petsc_t bypasses every counter).

Unlike the original one-shot linear path, a wrong fast-path verdict here
cannot return a stale solution: the loop measures the true residual at every
iterate and reassembles from iteration 1 on.

Regression: RHS-only reuse (same Q/Ahat/KSP handles), viscosity-field
invalidation with in-place refresh, and the time= veto, in
test_1018_rotated_freeslip. Production evidence on the original mechanism:
310 guarded Zhong A1 steps, flat RSS (PR #418 thread).

Underworld development team with AI support from Claude Code
…ey, the verdict goes collective

The PR #418 review's unresolved finding: the reuse key was built from
MeshVariable._state counters, which are BLIND to rampable UWexpression
constants — the #416 contract lets a constant change value with no state
bump, so a 2x viscosity ramp between solves reported "unchanged" and (on
the original one-shot path) returned a bit-identical stale solution while
the matrix-probe safety net was disabled by the very verdict it was meant
to check.

The reworked loop already made a stale verdict non-fatal (the true residual
is measured every iterate and iteration 1 onward always reassembles), but
the verdict itself must still be honest:

- the operator key now includes the packed constants[] values the kernels
  will actually assemble with, plus the JIT bundle key (covers an in-place
  kernel rewire). Measured on the ramp probe: the naive key reported
  workspace_reused=True for a 2x constant ramp; this key reports False and
  reassembles. Over-invalidation on RHS-only constant changes is accepted —
  reassembly is the safe default;
- if the constants manifest or the coefficient enumeration cannot be read,
  the fast path is forfeited outright — correctness first;
- the fast path additionally requires a self-measured linear hint (last
  solve converged in <= 1 increment): for a nonlinear model the cached
  operator is the previous solve's tangent, and the skip would only trade
  an assembly for a wasted increment;
- the match verdict is allgathered and must be unanimous before it gates
  any collective PETSc call — state counters follow rank-local writes, and
  a rank-divergent verdict is a deadlock;
- on a detected change the stored signature is poisoned before the in-place
  refresh, so an exception mid-refresh cannot leave a stale key that later
  matches half-updated values.

Regression: test_rotated_workspace_constant_ramp_invalidates (fail-before
validated on the naive key: the reused flag lies there) with its own armed-
fast-path negative control and a fresh-solver control at the ramped
viscosity; test_rotated_workspace_deform_invalidates re-proves the
mesh.deform teardown on the reworked cache.

Underworld development team with AI support from Claude Code
…cache, interface laws opt out

solve_with_fault drives the same rotated Newton loop, so the cross-solve
workspace decision had to be made explicitly for the fault machinery:

- FRICTIONLESS pair blocks are geometry (coincident-node pairing + fault
  normals live in Q, keyed by the fault registration in the geometry
  signature) — they cache. A warm repeat reuses the rotation; a cold
  re-solve rides the iteration-0 fast path; both match a fresh-solver
  control on the same mesh.
- INTERFACE-LAW solvers (viscous / Coulomb / rate-state) opt out entirely:
  the interface tangent is reassembled per iterate at the current slip
  rates and the reaction-fed normal stress is Picard-lagged solver state —
  neither is keyable registration state, so cache_allowed excludes them at
  the top of solve_rotated_freeslip.

Regression: test_fault_repeat_solve_composes_with_workspace_reuse covers
both arms, with a fresh-solver control for the cached arm and the absent-
cache assertion for the opt-out.

Underworld development team with AI support from Claude Code
Copilot AI lite review requested due to automatic review settings August 13, 2026 04:33

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.

Pull request overview

This PR introduces a cross-solve cache for the rotated strong free-slip Stokes solve, reusing the PETSc rotation/operator/KSP workspace across repeated solves to reduce rebuild cost and bound memory growth, while adding a constants-aware invalidation key (to catch rampable UWexpression constants) and a collective reuse verdict for MPI safety.

Changes:

  • Add a persistent rotated-workspace cache (geometry + structure tiers) and an iteration-0 “fast path” that can skip Jacobian/PtAP/PCSetUp when the operator is provably unchanged.
  • Extend invalidation to include packed constants[] values + JIT bundle key (and make reuse verdict collective across ranks); add workspace_reused / rotation_reused reporting.
  • Add time= pass-through on Stokes.solve() and veto the fast path for explicit time evaluation; add focused regression tests and a design note.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tests/test_1018_rotated_freeslip.py Adds regression coverage for workspace reuse, constants-ramp invalidation, and deform invalidation.
tests/test_0846_fault_contact.py Adds coverage that fault contact (frictionless vs interface-law) composes correctly with the rotated workspace cache.
src/underworld3/utilities/rotated_bc.py Implements cross-solve workspace caching, constants-aware operator signature, and collective reuse gating.
src/underworld3/systems/solvers.py Exposes time= on solve() and forwards it to the underlying solver call sites.
src/underworld3/cython/petsc_generic_snes_solvers.pyx Adds cache teardown hooks and vetoes the rotated fast path when time= is provided.
docs/developer/design/ROTATED_FREESLIP_LINEAR_REUSE.md Documents the reuse tiers, safety net, invalidation rules, and validation approach.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +513 to +523
from underworld3.utilities._jitextension import _pack_constants
try:
manifest = getattr(solver, "constants_manifest", None)
values = tuple(float(v) for v in _pack_constants(manifest)) \
if manifest else ()
except Exception:
return None
jit_key = getattr(solver, "_current_jit_cache_key", None)
if jit_key is None:
jit_key = getattr(solver, "_last_jit_cache_key", None)
return (str(jit_key), values)
… null before destroy

Two minors from the #543 review. (m2) The persisted operator_sig now
requires that this solve either reassembled Ahat or rode a key-matched
fast path - a poisoned solve exiting at iteration 0 without assembling
can no longer store a fresh key against unrefreshed values (the window
was bounded by the always-reassemble net; now it is closed). (m4) Both
teardown sites null the cache attribute before destroying, so an
exception mid-destroy leaves objects unreachable rather than arming a
double-destroy.

Underworld development team with AI support from Claude Code
@lmoresi

lmoresi commented Aug 13, 2026

Copy link
Copy Markdown
Member Author

Adversarial review — PR #543: rotated free-slip workspace reuse (rework of #418, fixes #417)

Reviewed independently of the implementer at f8c2b72 (3 commits on 689523f),
isolated worktree r543-review, amr-dev env, uw.__file__ verified into the
worktree's own site-packages, probes serial and sequential, np2 via the
worktree's own mpirun. Probe scripts: session scratchpad
probe1_staleness.pyprobe6d_true_window.py.

The structural claim we held #418 on is now real: the safety net
(true-residual convergence + always-reassemble-from-iteration-1) is
independent of the key it guards, and we could not make the branch return a
wrong answer — not with a deliberately corrupted key, not with BC/rheology/
geometry mutation, not with a fault-normal change, not at np2. Every finding
below is an honesty/latency wart or a hardening suggestion, none blocks.

MERGE-BLOCKERS

None. One condition we would attach: gthyagi's A1 re-validation segment on
this branch
(the PR itself proposes it) — the loop internals moved under
the cache since the 310-step production trail was measured; a 50-step
guarded restart from an existing checkpoint should land in the PR thread.

Findings (severity-ranked)

m1 (minor) — solver configuration latches into the cached KSP

_solve_rotated_iterative reads solver.tolerance (and the FMG/GAMG
choice, smoother variant) only at ctx creation; pre-PR the ctx died each
solve, now it persists until a geometry/BC teardown. Tightening
stokes.tolerance between solves leaves the inner Krylov at the OLD rtol:
the outer loop re-reads the new tolerance and still converges on the true
residual — measured (probe 1-D) 1e-4→1e-10 honoured, final rnorm 4.5e-13,
err vs a tight fresh control 1.8e-11 — but at extra increments, with the
Citcom inner-margin discipline (inner = 0.1×tol) silently violated. Same
class: a set_custom_fmg registration after the first solve is ignored
until the cache dies. Correctness unaffected. Suggest a config tuple
(tolerance, custom-mg identity, smoother variant) in the signature, or one
sentence in the design doc.

m2 (minor) — persist can store a key describing values Ahat does not hold

If the operator key mismatches (poison correctly fired) but the loop exits
at iteration 0 without ever assembling (a warm start whose TRUE residual
already passes under the changed constants — reachable when operator and
RHS co-scale), the persist block stores the NEW operator_sig against the
UNREFRESHED Ahat, violating its own comment's invariant ("the stored key
describes the operator values Ahat now holds"). Bounded by the safety net
(+1 increment, lying flag, never a wrong answer). Hardening: persist
operator_sig only if assemble ran at least once this solve, else keep
it poisoned (None).

m3 (hardening) — coefficient-variable enumeration reuse is safe by coincidence, not construction

The enumeration (cache["coefficient_variables"]) is reused whenever the
GEOMETRY signature matches (~line 866), yet it encodes an OPERATOR
property (which variables the kernels read). We hunted the stale window
hard (probes 6/6b/6c/6d): every dependency-changing route we could build —
Parameters constant→field, field→field with the new field already in the
aux layout — forced a full _build teardown before the solve (the cache
was already None; probe 6c shows _build() alone kills it), so the
enumeration was always re-derived and the ramp of the new field was always
detected (final err 3.6e-11). But nothing in the cache logic ENFORCES
that: the safety is inherited from _build's aux-layout invalidation
behaviour, which this module does not control. One-line hardening:
re-derive the enumeration whenever the JIT half of operator_sig
mismatches, not only on geometry change. (Worst case if the coincidence
ever breaks: permanent lying workspace_reused=True + one wasted
increment per solve — the safety net still guarantees the answer.)

m4 (nit) — destroy-then-null ordering in teardown

_destroy_rotated_linear_cache(cache) runs before
solver._rotated_linear_cache = None (rotated_bc.py lines 895–896 and the
pyx hook). An exception mid-destroy leaves the solver holding a
half-destroyed dict. Null-first is the safer order. (Inside the
destructors the #492 release discipline is right: destroyed entries are
None'd, double-calls are no-ops — verified by repeated
_reset_rotated_solver_cache()/_reset() in probe 3.)

Observation (positive) — rank-divergent state counters are unreachable via the sanctioned API

Our adversarial np2 variant (rank-0-only T.data write, then solve)
deadlocked INSIDE the data-access layer, not in the PR: the stack sample
shows the .data write hook itself calling
DM.createSubDM → DMClone → MPI_Allreduce while rank 1 sat at the probe's
own barrier. A sanctioned field write is already collective, so the
counter divergence the PR's allgather defends against cannot be produced
through .data at all. The allgather is belt-and-braces — cheap and
correct; keep it (.array/swarm/exception paths remain conceivable).

Observation — in practice the cache does not survive constitutive rewires

The PR text says the in-place rewire keeps the cache and the JIT key
catches the new kernels. In every constitutive-change flow we probed,
_build ended up on the full-rebuild branch (aux-layout hash) and the
cache was torn down before the solve — over-invalidation, the safe
direction, and irrelevant to the target time-stepping pattern (no rewires
there: 34/35 solves reused per the PR, 25/30 in our probe 3). The JIT-key
half of the signature is defensive depth today, not the working
invalidation path. No action; recorded so nobody later "optimises" the
teardown away believing the JIT key alone carries rewires.

Attack log

1. Staleness beyond the ramp (probe 1, serial, Q2/P1disc):

Change between solves invalidates / forfeits? err vs fresh control
second solver, same mesh caches independent (distinct handles); interleaved drift 0.0 0.0; ¼-viscosity ratio 0.0
add a Dirichlet wall full rebuild, rotation_reused=False 0.0
add a second rotated boundary full rebuild + boundaries in geometry sig 0.0
tolerance tightened 1e-4→1e-10 honoured on the true residual (m1) 1.8e-11
nonlinear rheology switched on, η(v) fast path defeated; linear_hint withdrawn after the 2-increment solve 5.7e-16
bodyforce expression REPLACED rebuild/rewire; no stale answer 3.4e-10
rampable-constant 2× ramp (THE #418 killer) their test: fast path defeated, halved velocity exact (in test, passes)
direct mesh.X.coords writes not a live path: the property returns scaled/wrapped arrays and writes never reach the PETSc coordinate vec; deform is the sanctioned route and tears the cache down (their test + probe 3) n/a

2. Iteration-0 fast path (probe 2-G): after a fast-path solve, poisoned
the key to force a slow-path in-place reassembly of the SAME Mat: every
constrained row identical, worst abs diff 0.0, |Ahat| unchanged to all
digits. The skipped zeroRowsColumns/PCSetUp is genuinely redundant.
Poisoning order verified in code AND by probe 2-I (hand-poison, ramp, no
fast path, err 3.5e-11): an interrupt in the poison→refresh window can only
under-reuse.

3. Collective discipline (probe 4, np2): code audit — every verdict
input is registration state, packed-constants (global), or goes through
the allgather; exception paths inside both signature helpers swallow to
None symmetrically (→ forfeit). Probe: fresh / RHS×2 / rank-VARYING RHS
values / constant ramp — flags allgathered at every solve, rank-identical:
[(F,F),(T,T),(T,T),(T,F)], ramp defeated on both ranks, no hang, final
err vs fresh control 1.2e-10. (The rank-asymmetric-write variant is
unreachable — see the positive observation above.)

4. The structural safety net (probe 2-H): monkeypatched
_operator_constants_signature to a frozen value (equivalent to reverting
the blind-spot fix), ramped 2×: workspace_reused=True (the lie fires),
halved-velocity error 1.7e-11, exactly +1 increment (2 vs 1). The net
that replaced the dropped matrix probe does what the PR claims — a
corrupted key costs work, never an answer.

5. Fault composition (their test + probes 5/5b): fault set passes (30).
Changing the fault's normal= override between solves is caught by the
geometry signature (rotation_reused=False); a COLD post-change solve
matches a fresh override control bit-identically (0.0, floor between
two fresh controls also 0.0). For the record: a WARM start straight across
the normal change landed 6.3e-2 off while reporting converged — with the
cache already destroyed (pre-PR behavior identical), in a deliberately
flux-incompatible override configuration, and with the loop's
mass-conservation gauge warning printed loudly. Pre-existing rotated-loop
warm-start semantics, not a cache defect.

6. Teardown (probe 3): 30 solves with 5 interleaved mesh.deform
teardown/rebuild cycles: 25/30 fast-pathed, ru_maxrss slope
0.79 MiB/solve including the five full rebuilds (PR's pure-reuse figure
0.12; high-water RSS overstates); repeated _reset() /
_reset_rotated_solver_cache() idempotent, no double-destroy; σ_nn
traction recovery off a fast-path solve doubles with the load (1.3e-10) —
verified in code that the recoveries read only reaction+boundaries, so
the "result dict survives cache teardown" contract holds. Custom-FMG
prolongation deref-not-destroy is correct (shared coarse Mats; the rotated
Pfine/Qv fall to petsc4py GC).

7. Tests & negative control: test_1018 22 passed; fault set
0845/0846/0847/0848 30 passed. House-rule negative control: swapped
commit 1's rotated_bc.py (naive key) into the INSTALLED copy → the ramp
test fails exactly at the lying-flag assertion, restored and re-verified.
The regression test has teeth, and its own internal negative control (fast
path proven armed before the ramp assertion) fires. Full gate
-m "level_1 and tier_a" (–test_0050): 599 passed, 0 failed, 17 skipped
(MPI-gated), 1 xfailed, in 8:13
.

Notes for the record

  • PR base is 2 commits behind origin/development; the divergence touches
    the pyx by 6 lines (Boundary flux on degree-3 traces: per-slot DOF identity, true edge-node coordinates, and the consistent line mass (#459) #537), disjoint from this change — expect a clean
    merge.
  • Commit 1 carries gthyagi's authorship, as it should.
  • time= semantics: petsc_t persists on the DM after a time= solve; a
    later no-time solve fast-paths against the operator assembled at that
    time, which is CONSISTENT (operator and residual agree). The veto covers
    the transition solve — the only place it must.
  • _pack_constants packs 0.0 for an un-floatable constant, but signature
    and kernels share the same packing, so the key cannot desync from the
    assembly.
  • Suggest (non-blocking): an np2 ptest_ for the reuse verdicts — the
    parallel evidence currently lives only in probe scripts, and
    tests/parallel/test_1064/1066 predate the cache.

Verdict

APPROVE (with the A1 re-run segment requested of gthyagi, and m2/m3 as
cheap hardenings here or in a follow-up). The #418 failure class — a silent
stale answer — is structurally closed: under deliberate key corruption the
branch still produced the correct field at +1 increment, and every
mutation we threw at the key (BCs, rheology, bodyforce, fault normals,
tolerances, np2 rank-varying state) either invalidated, forfeited, or was
rescued by the always-reassemble net. Memory (#417) stays bounded through
deform/rebuild cycles.

@lmoresi

lmoresi commented Aug 13, 2026

Copy link
Copy Markdown
Member Author

Response commit 2f17743 takes m2 (operator_sig persisted only when Ahat provably holds the keyed values) and m4 (null-before-destroy at both teardown sites); test_1018 22/22 after rebuild. m1 (config latched into the cached KSP) and m3 (coefficient-enumeration reuse safe by teardown coincidence, not construction) are tracked as a follow-up issue. The attached condition stands: @gthyagi — when you have a slot, a 50-step guarded A1 restart segment on this branch would re-anchor your 310-step production trail against the reworked loop; the memory numbers here (+0.12 MiB/solve over 35 solves, 2.10x warm-solve speedup) match your original measurements.

Underworld development team with AI support from Claude Code

@lmoresi
lmoresi merged commit e475246 into development Aug 13, 2026
2 checks passed
@lmoresi
lmoresi deleted the bugfix/pr418-rework branch August 13, 2026 06:09
lmoresi added a commit that referenced this pull request Aug 14, 2026
… the difference

CI's first diagnosis-grade report on test_rotated_workspace_deform_invalidates
said both solves converged from an IDENTICAL initial residual
(|r0| = 0.02785134866629431 to every digit, |r| ~ 1e-11 for both) and still
disagreed by 7.02e-02. Two solves that both drive the residual to machine zero
on the same system can only differ in the null space, so this round measures
the null space instead of arguing about it.

Solver side: the rigid-rotation gauge decision was invisible. Whether a mode is
admitted decides whether a component is projected out of the answer, and
_mode_satisfies_constraints made that call silently, per mode, from the
boundary normals. It now optionally records the constraint violation, the
operator violation and the verdict; _finalize_rotated_solution collects one
record per offered mode and the solve result carries it as "rotation_gauge".
Default arguments unchanged, so nothing else moves.

Test side, all of it reported in the failure message and all of it computed
EAGERLY, because an instrument that only runs when the test fails is an
instrument that has never been run:

- the difference field is decomposed onto the rigid-body span (the same modes
  the solver considers, plus translations, Gram-Schmidt'd in the same order,
  built from nodal coordinates so it does not inherit the machinery it is
  measuring). If the difference lives in the span, one solve admitted a mode
  the other rejected and this is a #543 gauge bug; if it lives off the span,
  the two operators differ and something survived the deform;
- per-solver constrained-row count, distinct-row count and boundary list;
- a point-location tally around the deform, the post-deform solve and the
  control, answering every call TWICE - as shipped and with the rejection
  radius set aside - and reporting how many answers the radius changed. That
  is the number that decides whether #556 is implicated, taken on whatever
  mesh the machine actually built rather than argued from a local run.

Three negative controls, so none of those numbers is unchecked: a pure rigid
rotation must decompose with 4e-18 of it off the span and a random field with
0.9985 of it off (both asserted on the mesh under test), and squeezing the
reach margin to 0.05 must make the radius comparison see answers change.

Measured here: 84 constrained rows for both solvers, one rotation mode offered
and rejected by both with identical violation 3.045e-01, gauge_removed False
for both, and during the deform 8 location calls over 2270 points with 38
returning -1 and radius_changed = 0. The locator changes nothing on this
platform. If CI reports otherwise, that is the finding.

Underworld development team with AI support from Claude Code
lmoresi added a commit that referenced this pull request Aug 14, 2026
…es them

The test made two claims in one assertion. One is well posed and is what #543
wrote it for: the deform invalidated the workspace. The other is not: that a
post-deform solve matches a fresh control to 1e-6, on a system that does not
determine its own answer in one direction (#560).

They are now separate.

(a) stays hard: rotation_reused and workspace_reused both False, the locator's
rejection radius followed the deform, both solves converged.

(b) is narrowed, not relaxed. Rotated free-slip on a curved boundary loses the
constant-pressure gauge, and the solution acquires a component along one
unpinned direction whose amplitude is round-off. Measured: a coordinate change
of two machine epsilons (4.44e-16) moves the velocity by 1.33e-01, and the move
does not scale with the perturbation - 4.4e-16, 2.2e-15, 1e-14, 1e-12 and 1e-9
all give between 4e-2 and 2e-1. That is why this assertion was green on macOS
(err exactly 0.0 in 81 consecutive runs across two PETSc toolchains and nine
PYTHONHASHSEEDs) and intermittently red on CI: it passes only where the two
assemblies agree bitwise.

The unpinned subspace is exactly one-dimensional - five different perturbations
move the answer along the same direction to cosine 1.000000, the normalised
difference set has singular values [2.236, 4.4e-9, 3.4e-9, 2.3e-9, 1.7e-9], and
removing the leading direction leaves 2e-9 of each difference. So the test
measures that direction with one extra perturbed solve and requires the two
solutions to agree in every OTHER direction, at the same 1e-6 it always used.
The tolerance is untouched; it is the claim that is made honest.

Two things keep it from becoming a rubber stamp. A negative control injects a
1e-3 discrepancy orthogonal to the unpinned direction and asserts it survives
the projection (measured 1.000e-03 against a 1e-4 floor), so the projection
cannot absorb a real disagreement. And the test branches on what it measures:
if a 2-eps perturbation stops moving the answer - i.e. when #560 is fixed - the
projection becomes a no-op and the solutions are compared directly again, with
the branch reported in the failure message. Fixing #560 strengthens this test
instead of breaking it.

All the instrumentation stays: the gauge decisions, the constrained-row counts,
the locator tallies and the rigid-body decomposition are what turned an
unreadable CI failure into a filed defect, and they are the diagnostic for the
next one.

Verified: this test 10/10 in amr-dev and 10/10 in dev, the CI batch shape
(tests/test_101*py tests/test_102*py) 137 passed in dev, full level_1/tier_a
gate 627 passed 0 failed.

Underworld development team with AI support from Claude Code
lmoresi added a commit that referenced this pull request Aug 14, 2026
…nment-check the cell hint (items 1-3 of #551, fixes the #432 class) (#556)

* A point the local mesh cannot own should be rejected, not walked 50 times

The lost-point walk in _get_closest_local_cells_internal tried the 50 nearest
cell centroids and only stopped when every lost point had been found. A point
no local cell could own therefore paid all 50 rounds - 51 containment tests
against 1 for an owned point, measured - and every point already located was
re-tested on each of them, so one unfindable point charged the whole batch.
In parallel the fraction of points a rank does not own is exactly what grows
with rank count.

Two bounds. A point inside a cell is no further from its nearest kd-tree
control point than from that cell's centroid, which for a convex cell is
within the cell's vertex reach; the largest local reach is now recorded with
the kd-tree and a lost point beyond twice that distance is rejected before the
walk starts. The factor of two is slack for the in-cell test's face tolerance
and for badly shaped cells. And the working set shrinks: a point leaves as
soon as a cell claims it, or as soon as the sorted neighbour distances pass
the rejection radius.

Measured on the #551 dossier probes: a foreign point costs 1.0 containment
tests instead of 51.0 (0.53 us/point instead of 7.99 serial, 0.79 instead of
9.6 at np=4), and one unfindable point in a batch of 1000 adds 3 point-tests
instead of 991 (2-D) or 12251 (3-D).

The nearest containing centroid now wins, where before the winner was the LAST
of up to 50 rounds and so depended on whether some unrelated point in the same
batch was findable. Across a nine-set battery in 2-D and 3-D at np=1/2/4, 15 of
~450000 located cells change; every one is a point that both the old and the
new cell contain.

Addresses item 1 of #551.

Underworld development team with AI support from Claude Code

* The cell hint that bypasses DMLocatePoints must contain the point

Serial simplex meshes assert "exact" location capability, which makes the UW3
cell hint authoritative and skips PETSc's DMLocatePoints entirely - while the
hint they handed over was get_closest_cells, a nearest-CONTROL-POINT kd-tree
lookup with no containment test at all. On a tetrahedron nothing downstream can
rescue that: the only remaining guard is a componentwise box clamp on the
reference coordinates, and the reference tet is not the reference box. A query
on a shared edge was answered by extrapolating the basis of a cell that does
not contain it. That is #432, a recurrence of #390.

The serial-simplex branch now takes _robust_owning_cells, the containment-
checked locator every other authoritative path already used, so the three
branches collapse to one call. Points it cannot place come back as -1, which
the C bypass treats as a non-claim; they surface in unlocated_mask and take the
RBF fallback that is already plumbed.

The alternatives were worse. Restricting the "exact" assertion pushes serial
simplex meshes back onto DMLocatePoints, which is slower and re-opens the #390
class of silent drops the bypass was added to close. A barycentric clamp in C
only pins a wrong cell's reference coordinates to that cell's boundary, which
gives the edge value of the WRONG cell - right only for continuous fields, and
no help at all when the nominated cell is not adjacent to the point.

Measured cost: one extra containment test per point, 1.0 tests per point for
interior, on-face and on-edge queries. Measured benefit: 3-D P1 evaluation at
quarter-points along cell edges was off by 3.0e-01 at 17 of 1318 points and is
now exact.

Addresses item 3 of #551 and closes #432.

Underworld development team with AI support from Claude Code

* Classify and locate in one pass, so evaluation does not search twice

points_in_domain located the points near the domain boundary, returned a
boolean mask and threw the owning cells away; petsc_interpolate then located
every interior point again. The near-boundary points were being searched for
twice per evaluate call.

Mesh._classify_points_in_domain returns both - the mask and the cells the
classification actually looked up, with -1 meaning "not looked up" for an
interior point and "not in the local mesh" for an exterior one. evaluate
passes those to petsc_interpolate as cell_hints, which searches only for the
entries still marked -1, and only on the DMInterpolation cache miss that needs
them. A cache hit locates nothing at all, which the first version of this
change got wrong: filling the whole hint array in the classifier made serial
evaluate 29% slower because it searched on every call, cached or not.

points_in_domain keeps its signature, its answer and its cost - it is now a
one-line wrapper and does not search on the interpolator's behalf. Only the
robust locator's answer is kept as a hint; the cell-wall test the serial
classifier uses runs at a different face tolerance and its answer is a
classification, not a hint.

Measured on the #551 dossier probes: containment point-tests per located point
inside evaluate fall from 2.28 to 1.10 at np=2, 3.35 to 1.20 at np=4 and 4.19
to 1.27 at np=8; evaluate wall time 0.0382 to 0.0361 s at np=4.

Addresses item 2 of #551.

Underworld development team with AI support from Claude Code

* Pin the locator's two contracts: the hint contains the point, rejection is O(1)

#432 and #390 have both been fixed and returned, and there was no regression
test pinning on-edge or on-face queries. tests/test_0761_point_locator.py adds
them in 2-D and 3-D.

The oracle is the closed-form P1 value (1-t)*u_a + t*u_b along a cell edge,
computed WITHOUT uw.function.evaluate so it cannot inherit the defect it is
testing for - the same discipline test_0753's arbitrary-coarse-field reference
uses, and for the same reason. Covers shared vertices, edge midpoints, edge
quarter-points and 3-D face centroids.

The performance guard counts containment tests per point rather than seconds.
The count is what the algorithm does; a wall time is what the machine was doing
at the time, and the house has been burned by timing tests before. Parallel
wall-clock numbers belong in the PR, not in an assert.

Negative controls, because a test that cannot fail proves nothing. The nodal
field is MEASURED to vary by more than 0.1 between a cell edge and its
midpoint, so a wrong-cell interpolant cannot pass by being smooth (a linear
field cannot test neighbour selection). get_closest_cells is shown to nominate
cells that do not contain the query, so the edge test is pinning something
real - and only at 3-D quarter-points, since a midpoint is never misassigned
and 2-D never is, which is why #432 is a 3-D report; the 2-D case skips with
that count in its message. The containment counter is asserted to fire, so the
bounds cannot pass by instrumenting nothing.

Validated fail-before with the fixes stashed and the tree rebuilt: 3-D P1
evaluation at t=0.25 off by 3.036e-01 at 17 of 1318 points; a point outside the
mesh costing 51.0 containment tests; one unlocatable point adding 991 (2-D) and
12251 (3-D) point-tests to a batch of 1000.

Underworld development team with AI support from Claude Code

* Fill the points the locator loses instead of returning NaN

The RBF fallback in petsc_interpolate has never run. It iterates
mesh.vars.values(), and mesh.vars is a weakref.WeakValueDictionary
whose .values() is a GENERATOR, not a view: the dofcount loop a few
lines earlier consumes it, so the fallback loop iterates nothing and
the NaN that DMInterpolationEvaluate_UW writes for an unplaced point
is returned to the caller.

That did not matter until the previous commit in this branch, which
routed serial simplex evaluation through the containment-checked
locator. That locator returns -1 - the older nearest-control-point
hint never did - so the dead rung became load-bearing on the most
common configuration in the library. Measured on a graded 3-D simplex
box (cellSize 1/8, deformed x -> x**4), 3000 interior queries: the
merge base returns 0 NaN, this branch returned 1.

Materialising the list fixes it: same probe, 0 NaN, and the point
takes the RBF value (0.2802) the rung was always supposed to write.

The same exhausted generator was read again by the continuity gate,
so all() over it was vacuously True and the gate has never bound.
Un-breaking it is a behaviour change, so it is measured rather than
assumed. The gate only does anything on meshes whose measured
location capability is "continuous" - warped hexes, the cubed-sphere
class; simplex, quad, annulus and rectilinear hex boxes are all
"exact", where the gate is a no-op either way. On a warped hex box
carrying a P1 and a P0, binding it over EVERY variable on the mesh
would cost the CONTINUOUS field a factor of 15 in accuracy (linear
field, max error 8.0e-3 -> 1.2e-1 at 62 of 1500 interior points)
because one discontinuous variable elsewhere took every evaluation
off the authoritative path. Scoped instead to the variables the call
actually asks for - which is what the surrounding comment always said
the policy was - the continuous field is bit-identical to before and
only the P0 moves, by up to 1.71 on a field of range 2, which is the
O(jump) correction the gate exists to make.

Also here, from the same review:

- petsc_interpolate copies cell_hints before filling its -1 entries.
  np.ascontiguousarray hands back the caller's own array when it is
  already int64 and contiguous, and cell_hints is a documented
  keyword, so the fill was writing through somebody else's array.
- _build_kd_tree_index_PIC and _build_kd_tree_index_DS are deleted.
  Both set _index without the new _local_cell_reach, which is the one
  way a stale rejection radius could outlive the geometry it was
  measured on; both have had zero callers for a long time and are
  already on the readability review's delete list (READ-31).
- The tie-break change is written down where it can be found: which
  containing cell a shared vertex/edge/face query returns has moved,
  and for a discontinuous field the cell is the answer.
- The absolute face-tolerance slab and the mesh-relative rejection
  radius are noted as the different scales they are, with the domain
  size at which they would cross (about 5e-6 across, measured clean
  at 1e-4 and at 6371).

Underworld development team with AI support from Claude Code

* Test the direction of the rejection radius that would be silent

Nothing asserted that a point a local cell really does contain is
never rejected by the new radius, which is the failure mode that
would not announce itself: the point vanishes into the RBF fallback
in serial, or is claimed by nobody in parallel.

Four tests, all with their own negative control.

A brute-force oracle - every query against every local cell, with the
same containment predicate the locator uses - says which points are
genuinely owned, and the reach margin is swept to prove the probe
fires. Serial: 2-D {2.0: 0, 1.0: 0, 0.5: 5, 0.25: 16}, 3-D
{2.0: 0, 1.0: 0, 0.5: 4, 0.25: 168} out of 800 interior points. The
bound is tight at about 1.0, so the shipped 2.0 carries a factor of
two over the first observable loss. 0 at the shipped margin on every
rank at np2 and np4 as well.

A P0 discontinuous field evaluated at shared mesh vertices pins the
tie-break: the value must belong to one of the cells that contains
the query, not a specific cell (that was never the contract, and
pinning it would break on the next legitimate change). Every vertex
in the set is contained by 18 to 44 cells and their values differ by
more than 0.5, so a wrong cell is visible.

The RBF fallback rung gets a fault-injection test: five points are
forced to -1 and the result must be finite AND equal the RBF value
AND leave the other 395 points bit-identical. Waiting for a graded
mesh to lose a point naturally works, but how many it loses is a
property of whatever gmsh produced that day. Confirmed fail-before by
rebuilding with the one-line generator fix reverted: exactly the five
injected points come back NaN.

The reach invalidation was asserted in prose only. Deform a 2-D box by
50 and the stored reach must follow exactly, and every point in the
expanded domain must still be located.

Finally, test_the_classifier_hands_over_the_cells_it_located ended
with its real assertion inside `if offered.any():`, and offered is
ALWAYS empty in serial - 0 hints for 1465 interior points, against
462 of 462 at np4 - so the strongest claim in item 2's only test was
a no-op in the default test run. It now asserts the serial reality
(no hints, by design) and requires hints under MPI.

Underworld development team with AI support from Claude Code

* Make the deform-invalidation test say what went wrong when it fails

PR #556 failed CI on test_rotated_workspace_deform_invalidates with
"post-deform solve differs from fresh control by 1.16e-01", and the
failure does not reproduce here. Not in the amr-dev toolchain (custom
AMR PETSc), not in the dev toolchain CI itself uses (conda PETSc),
not as a single test, not as the whole file, not in the exact CI
batch shape (pytest tests/test_101*py tests/test_102*py: 137 passed
in both environments), with or without CI's environment variables.
Locally err is not small, it is exactly 0.0: one Krylov iteration,
|r| = 8.267546030330813e-12 bit-identical between the post-deform
solve and the fresh control.

The locator work in this PR is measurably inert in this test:

- the mesh is a StructuredQuadBox, and for a quad/hex mesh the merge
  base ALREADY located through _robust_owning_cells - item 3 changed
  the serial SIMPLEX path only, so the evaluate route here is
  unchanged;
- there are 8 point-location calls during the test (from
  swarm.migrate -> points_in_domain and from global_evaluate, not
  from the assembly). Running every one of them twice, with the new
  rejection radius and with it disabled, gives identical cells:
  0 differences out of 2270 points;
- the deformed quad box measures location capability "exact" before
  and after the deform, so _hint_is_authoritative is True whatever
  the field continuity and the continuity gate cannot bind;
- in serial the classifier hands over no cell hints at all, so item 2
  is inert too;
- the locator reach follows the deform (0.070710678 -> 0.073041055).

Rather than guess at a fix for something that cannot be reproduced,
the test now distinguishes the two things it could be. Both solvers
assemble the same system on the same deformed mesh from a zero guess,
so any deterministic solver owes them the same answer: if both report
converged and the answers still differ, the OPERATORS differ and
something survived the deform; if the convergence reports differ, the
linear solves did. The failure message carries both solves' reason,
iteration counts and residuals plus the locator reach before and
after, and the reach invalidation is now asserted here as well as in
test_0761 (the review's minor m1) because this is the only test in
the suite that deforms a mesh between two solves.

Underworld development team with AI support from Claude Code

* Record which rigid-body modes the rotated gauge admits, and decompose the difference

CI's first diagnosis-grade report on test_rotated_workspace_deform_invalidates
said both solves converged from an IDENTICAL initial residual
(|r0| = 0.02785134866629431 to every digit, |r| ~ 1e-11 for both) and still
disagreed by 7.02e-02. Two solves that both drive the residual to machine zero
on the same system can only differ in the null space, so this round measures
the null space instead of arguing about it.

Solver side: the rigid-rotation gauge decision was invisible. Whether a mode is
admitted decides whether a component is projected out of the answer, and
_mode_satisfies_constraints made that call silently, per mode, from the
boundary normals. It now optionally records the constraint violation, the
operator violation and the verdict; _finalize_rotated_solution collects one
record per offered mode and the solve result carries it as "rotation_gauge".
Default arguments unchanged, so nothing else moves.

Test side, all of it reported in the failure message and all of it computed
EAGERLY, because an instrument that only runs when the test fails is an
instrument that has never been run:

- the difference field is decomposed onto the rigid-body span (the same modes
  the solver considers, plus translations, Gram-Schmidt'd in the same order,
  built from nodal coordinates so it does not inherit the machinery it is
  measuring). If the difference lives in the span, one solve admitted a mode
  the other rejected and this is a #543 gauge bug; if it lives off the span,
  the two operators differ and something survived the deform;
- per-solver constrained-row count, distinct-row count and boundary list;
- a point-location tally around the deform, the post-deform solve and the
  control, answering every call TWICE - as shipped and with the rejection
  radius set aside - and reporting how many answers the radius changed. That
  is the number that decides whether #556 is implicated, taken on whatever
  mesh the machine actually built rather than argued from a local run.

Three negative controls, so none of those numbers is unchecked: a pure rigid
rotation must decompose with 4e-18 of it off the span and a random field with
0.9985 of it off (both asserted on the mesh under test), and squeezing the
reach margin to 0.05 must make the radius comparison see answers change.

Measured here: 84 constrained rows for both solvers, one rotation mode offered
and rejected by both with identical violation 3.045e-01, gauge_removed False
for both, and during the deform 8 location calls over 2270 points with 38
returning -1 and radius_changed = 0. The locator changes nothing on this
platform. If CI reports otherwise, that is the finding.

Underworld development team with AI support from Claude Code

* Compare the deform-invalidation solves only where the system determines them

The test made two claims in one assertion. One is well posed and is what #543
wrote it for: the deform invalidated the workspace. The other is not: that a
post-deform solve matches a fresh control to 1e-6, on a system that does not
determine its own answer in one direction (#560).

They are now separate.

(a) stays hard: rotation_reused and workspace_reused both False, the locator's
rejection radius followed the deform, both solves converged.

(b) is narrowed, not relaxed. Rotated free-slip on a curved boundary loses the
constant-pressure gauge, and the solution acquires a component along one
unpinned direction whose amplitude is round-off. Measured: a coordinate change
of two machine epsilons (4.44e-16) moves the velocity by 1.33e-01, and the move
does not scale with the perturbation - 4.4e-16, 2.2e-15, 1e-14, 1e-12 and 1e-9
all give between 4e-2 and 2e-1. That is why this assertion was green on macOS
(err exactly 0.0 in 81 consecutive runs across two PETSc toolchains and nine
PYTHONHASHSEEDs) and intermittently red on CI: it passes only where the two
assemblies agree bitwise.

The unpinned subspace is exactly one-dimensional - five different perturbations
move the answer along the same direction to cosine 1.000000, the normalised
difference set has singular values [2.236, 4.4e-9, 3.4e-9, 2.3e-9, 1.7e-9], and
removing the leading direction leaves 2e-9 of each difference. So the test
measures that direction with one extra perturbed solve and requires the two
solutions to agree in every OTHER direction, at the same 1e-6 it always used.
The tolerance is untouched; it is the claim that is made honest.

Two things keep it from becoming a rubber stamp. A negative control injects a
1e-3 discrepancy orthogonal to the unpinned direction and asserts it survives
the projection (measured 1.000e-03 against a 1e-4 floor), so the projection
cannot absorb a real disagreement. And the test branches on what it measures:
if a 2-eps perturbation stops moving the answer - i.e. when #560 is fixed - the
projection becomes a no-op and the solutions are compared directly again, with
the branch reported in the failure message. Fixing #560 strengthens this test
instead of breaking it.

All the instrumentation stays: the gauge decisions, the constrained-row counts,
the locator tallies and the rigid-body decomposition are what turned an
unreadable CI failure into a filed defect, and they are the diagnostic for the
next one.

Verified: this test 10/10 in amr-dev and 10/10 in dev, the CI batch shape
(tests/test_101*py tests/test_102*py) 137 passed in dev, full level_1/tier_a
gate 627 passed 0 failed.

Underworld development team with AI support from Claude Code
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.

3 participants