Skip to content

Release v3.2.0 - #805

Merged
lmoresi merged 467 commits into
mainfrom
release/v3.2.0
Oct 13, 2026
Merged

lmoresi merged 467 commits into
mainfrom
release/v3.2.0

Conversation

@jcgraciosa

Copy link
Copy Markdown
Contributor

Underworld3 v3.2.0

Highlights

Supported (validated)

  • Units and Scaling System — Pint-backed dimensional quantities, non-dimensionalisation, and unit-aware expression arithmetic.
  • Winslow Mesh Smoother — Parallel-safe interior mesh smoothing (smooth_mesh_interior) — the foundation the adaptive movers and geometric multigrid build on.
  • Geometric Multigrid / FMG Preconditioner — Automatic geometric full-multigrid preconditioning for the SNES solvers on refinement meshes (the preconditioner property).
  • Constant Nullspace (singular scalar solvers) — SNES_Scalar.constant_nullspace returns the minimum-norm solution for pure-Neumann / closed-manifold scalar problems whose operator has a constant kernel (the singular Poisson case).
  • Boundary Conditions on Curved Surfaces — Free-slip and traction BCs applied with true geometric normals rather than the flat-facet approximation, via a nodal rotation of the solution space.
  • Navier-Stokes with SUPG Stabilisation — Inertial flow with streamline-upwind Petrov-Galerkin stabilisation, composed from the transport-plugin interface.
  • Variable Data Access Contract — One canonical write path for mesh and swarm variable data: a read-only snapshot, intercepted ufunc writes, and a context manager that flushes every dirty variable collectively.
  • Checkpoint and Restart — In-memory snapshots, on-disk checkpoints, and exact same-mesh reload through the PETSc DMPlex section/vector path.
  • Analytic Benchmark Solutions — Closed-form solutions (SolKx, SolKz, SolDB, Richards, transport, Zhong2008 shell response) used to verify solver convergence rates.
  • Materials Registry and Distribution — Separates what a material is (a named property table) from where it is (particle-carried or region-based), so one rheology can be attached either way without rewriting it.
  • Stress Tensor Visualisation — Principal-stress glyphs and stress trajectories from projected deviatoric stress plus pressure.
  • Constitutive Models — Viscous, viscoplastic, viscoelastic-plastic, diffusion and Darcy relationships plugged into the SNES solvers. Transverse isotropy is a separate entry: it carries an unresolved accuracy drift.
  • Material History Transport — Semi-Lagrangian, backward-differentiation and Adams-Moulton flux forms behind one D/Dt interface. Storage of history at quadrature points is a separate entry.

Preview (present, unguaranteed)

  • MMPDE Mesh Mover / Adaptive Remeshing — follow_metric / OT mesh movement and variable transfer for anisotropic mesh adaptation.
  • Transverse-Isotropic Rheology — Anisotropic viscosity with a director, including transverse-isotropic VEP reporting the yield-limited weak-plane viscosity. Present and much improved this release; one accuracy drift remains unexplained.
  • Fault Networks and Split-Node Faults — Faults as real mesh discontinuities rather than smeared weak zones: specified once as a trace and hierarchy, then realised as a split-node cut with contact laws or as a volumetric weak plane.
  • Integration-Point Variables — Material history stored at quadrature points rather than at mesh nodes, with particle repopulation, mid-time swarm stepping and viscoelastic stress history carried on particles.
  • Point Location and Function Evaluation — Locating points in a distributed mesh and evaluating symbolic functions there, with an RBF fallback ladder and cheap rejection of foreign points.
  • Solver Control and Reporting — Per-iteration solver callbacks, convergence and tolerance reporting, and detection of runs that stall.
  • Consistent Boundary Flux Recovery — Recovers a pointwise surface flux (heat flux, or traction) from the interior residual at essential-BC nodes, de-smeared with the boundary mass matrix.
  • Prognostic Free Surface — Surface height relaxes toward the stress-derived equilibrium topography using an L-stable exponential integrator that cannot overshoot; now dimension-general and running on a 3-D spherical shell.
  • Geoid and Self-Gravity Response (experimental) — Coefficient-only geoid and self-gravity response functions for spherical shells and cylindrical annuli, independent of the Stokes discretisation.

Bug Fixes

New Features

Improvements

Breaking Changes

Contributors

Thanks to everyone who contributed to this release:

461 commits since v3.1.0, touching 136 files under src/.


Process notes. No v3.2.0rc1 — validation was already complete and clean, so an RC would have
published a candidate nobody needed. Steps 6a, 6b and 7 all ran on this branch: serial levels 1,2,3
clean, MPI at 2 and 4 ranks clean, gate 22 entries / 699 tests / no downgrades.

One known failure: test_1063_constrained_freeslip_parallel[ti] moves its accuracy anchor +1.8%
(1% tolerance), identically at 2 and 4 ranks, so not a partition effect. transverse-isotropy is
claimed preview for that reason; issue to follow.

lmoresi and others added 30 commits August 10, 2026 18:40
…xactness tests

The recovered review gates move into _validity_and_orientation_gates(),
called by place_sheet AND place_thin_volume (the embed shares the sewing
skeleton, so it shares the defect classes). check_faces is promoted from
serial-only to every rank count: it is issue #520's oracle, the plain
distributed box passes it cleanly, and with the vertex SF attached before
the interpolate the rebuilt mesh passes it too — so the gate that found the
defect now stands guard in every parallel test that places anything.
check_geometry stays serial-only (measured false-positives on healthy
distributed meshes on this stack).

New tests: P2 Poisson must reproduce a quadratic on the placed-sheet mesh
(test_0854) and through the embedded zone (test_0855) to solver precision —
the end-to-end assembly oracle that was missing when the mixed-handedness
defect shipped: a mesh can pass conformity, Euler, exact volume and every
abs()-based gate while assembling a wrong operator; a contained exact
solution cannot be faked. Measured on the fixed build: max error 1e-11
(sheet) and 9e-16 (volume), serial and np=2,4; plain Stokes on the placed
mesh at np=2 is 12.1 s against the base box's 7.6 s — issue #520's
acceptance was 2x, the defect measured 3594 s.

Underworld development team with AI support from Claude Code
…terface-orientation

Fix #520: seam cone reconciliation + the recovered orientation fix — parallel FE on placed meshes works
…n test

The interface-cone fix (#521, closing #520) verifies independently on
this branch: CheckFaces green at np=2 on the rebuilt mesh, placed-mesh
plain Stokes 24 s (was 3594 s), and — the acceptance that motivated
the whole ladder — the COMPOSED chain place_sheet -> split_fault ->
frictionless contact at np=2 converges in 18 s with the serial answer
(peak slip 0.1461 vs 0.1462) and machine-zero no-opening leak.
ptest_0852 locks that chain as a regression gate: the solve IS the
gate, because both #518-era defects passed every topological check.

Underworld development team with AI support from Claude Code
build(mesher="place") runs the native chain — static base, edge_split
graded adaptation along the network, each prepared rim triangulated at
h (interior grid; centreline points for ligament-trimmed strips; edge
flips kill all-rim triangles at corners — the split's precondition),
place_sheet per piece (clearance 0.8, the measured working window on
edge_split children), then split. Non-cumulative and parallel-capable
(ptest_0852). "embed" stays the network default: on graded bases the
placed composition builds and converges but the solve is pathological
(3850 s vs 125 s embed at finer h) — recorded as the open
operator-health item; on uniform bases place is healthy (22 s incl
solve at np=2).

Underworld development team with AI support from Claude Code
…d (lifecycle half 1) (#522)

The design ruling this serves: a fault is ADDED to the running distributed
mesh and a spent one DELETED, with no redistribution ever. Removal is the
inverse placement: the object's labels seed the carve (cells = a thin
volume, faces = a sheet; a zone's companion _skin label goes with it), the
cavity is refilled by a PLAIN gmsh fill at the background scale — erasing
the layer-scale grading — and the labels vanish with their points (gated:
globally empty afterwards). Every OTHER embedded surface is held and
count-gated exactly as a placement holds it; the interface readers gain an
exclude= parameter so a removal does not hold its own object against
itself. Parallel via the same gather-first surgery; the object's geometry
is allgathered from its labels first, so removal works whatever the current
distribution.

Composing the lifecycle surfaced three robustness fixes, all measured:

- The sheet carve gains the volume carve's shell-growth: a sheet placed
  into a REFILLED region can pinch against the new connectivity where the
  same sheet in the original mesh did not. The growth is now one shared
  helper (_closed_shell_3d) used by every 3-D carve — the flagged
  unification debt, paid.
- The sheet carve gains the centroid rule: the plane-straddle test covers
  triangle interiors, and a refill's locally-small cells can straddle a rim
  EDGE with every vertex outside reach (gmsh: "a segment and a facet
  intersect"). Any cell whose centre is within reach of the bounded sheet
  is dropped.
- Surgery-rank catches broadened to Exception: a raw gmsh error is a plain
  Exception, and an uncaught raise on the surgery rank is a HANG for its
  peers — every failure must become a collective refusal.
- The removal carve does NOT use the centroid rule (nothing is being
  embedded, there are no straddlers to catch); with it, a sheet removal
  reached the domain wall that the sheet's own placement cleared.

Round trip measured at np=1..4: add zone -> FE-exact (9e-16) -> remove ->
FE-exact (4e-11), cell count back within ~8% of base -> add sheet in the
SAME region -> remove again -> FE-exact. Cell counts after a refill are
partition-sensitive (gmsh fill vs shell node ordering) and are not part of
the contract; the invariants are.

tests/test_0856 (5 serial, number claimed in the ledger) and ptest_0856
(np=2,4); suites 0853-0855 green against the widened sheet carve.

Underworld development team with AI support from Claude Code
… count (#523)

The lifecycle ruling's second half, first instalment. The uninterp +
SF-first + DMPlexInterpolate rebuild is generalised to any dimension
(_rebuild_sewn: cone size dim+1, the handedness flip reads signed area in
2-D, join recovery over edges alone) and BOTH 2-D serial paths now go
through it — reconnect.rebuild_cavities leaves the placement family. The
handedness census (_cell_volumes_signed6) is dimension-generic too: signed
2*area in 2-D, same structural point (abs() is blind to inversion).

place_along_lines runs distributed via _place_one_parallel: mark by
distance, gather the region to one rank, carve and fill there, rebuild
collectively, label by the chain's new ids, gate globally (area conserved,
inversion, the validity battery with check_faces at every rank count, held
surfaces compared per rank — the mesh outside the region is untouched, so
per-rank equality is exactly the right gate). Scope, stated not fudged:
INTERIOR surfaces — a wall-touching end needs the end-settling machinery
(wall-vertex slides, facet splits) whose collective form is not built, and
refuses collectively with the reason; the serial path keeps the full
feature set. Wall proximity is judged against TRUE wall segments (support 1
AND unshared — a seam edge also has local support 1, the 3-D lesson one
dimension down).

Measured: same facet count as serial and P2 FE exactness (x^2+y^2 to 1e-11)
at np=1..4. ptest_0853 added (FE oracle + collective wall refusal); all
serial suites and the 3-D ptests green. Next instalment: the 2-D ribbon and
2-D removal on the same skeleton.

Underworld development team with AI support from Claude Code
…oth dimensions (#524)

The lifecycle ruling's remaining instalments. _place_thin_volume_2d is
rewritten in the collective form (rank-0 assembly + broadcast, mark by skin
distance, gather, target carve with ring growth and orphan promotion, holes
fill, one rebuild, cell+skin labels by join, collective gates incl. the
validity battery) — serial is the same path at size 1, and the 2-D serial
refusal is gone. remove_embedded gains its 2-D branch on the same skeleton
(label-seeded ring carve, PLAIN background-scale fill); the face soup and
the interface readers generalise (edges as the 2-D facets; the exclusion
that stops a removal holding its own object against itself).

The composition is now the tested contract in BOTH dimensions: zone ->
FE-exact -> remove -> FE-exact -> place a line in the same region ->
remove again, at np=1..4, with zero area drift — formalised as
test_the_two_dimensional_lifecycle_composes (serial) and the two
distributed lifecycle tests in ptest_0856 (2-D and 3-D, each stage held to
the P2 quadratic oracle). Suites 0853-0856 and all four ptest files green
at np=2 and 4.

Underworld development team with AI support from Claude Code
… bowl cavity + remeshed cap) (#525)

The science case this machinery exists for: plate-boundary faults intersect
the free surface. Three pieces, spike-proven first (outcrop_sheet_spike.py):

1. THE SPECIFY-LONG CONTRACT (ruling 2026-08-11): sheets may extend PAST
   the domain. _clip_sheet_to_box Sutherland-Hodgman-clips each triangle
   against the box planes — cut points snap exactly onto the plane and are
   computed in canonical edge order so shared edges dedupe bitwise — and
   _outcrop_chain extracts the trace as one ordered polyline on ONE wall
   (box-edge and multi-wall outcrops refuse with the reason).

2. THE BOWL CAVITY: with an outcrop, wall vertices inside the cap become
   victims and _closed_shell_3d returns the dropped cells' wall faces as
   CAP faces (any other wall contact still refuses); the manifold check
   runs on shell UNION cap. The cap over the cavity is PRE-MESHED by the
   2-D fill in the wall plane's own coordinates — rim ring verbatim,
   outcrop chain embedded free-end — because a geo surface bounded by the
   discrete rim RESAMPLES it (measured: 84 renumbered nodes, volume 1.31,
   Euler -46; MeshOnlyEmpty does not prevent it). The 3-D fill takes the
   surface loop [shell + discrete cap] with the sheet embedded, outcrop
   nodes shared by tag between cap and sheet.

3. EXPLICIT RELABELLING: the cap's new wall faces carry exactly the
   (label, value) pairs the replaced faces carried, written with their full
   closures — joins cannot recover new points, and an unlabelled boundary
   patch silently loses its Dirichlet conditions.

Measured, serial and np=2..4: identical facet counts, P2 quadratic
FE-exact (2e-11..5e-11) WITH a Dirichlet condition gripping the remeshed
Top, full Top-label coverage on the new boundary faces. Interior sheets
unchanged (suites 0853-0856, 44 serial + placement ptests green). The
outcropping thin-volume BAND is the remaining instalment (plan carries the
design: cap-with-hole + interior skin as one closed loop).

Underworld development team with AI support from Claude Code
* Outcropping zones: the fault zone leaves its band on the surface

The thin-volume form of the outcrop, completing "get all the
implementation done". The assembly is clipped against the domain box in
OCC (specify-long: patches may protrude; clipped faces snap exactly onto
the wall plane), and a clipped face left in ONE wall plane becomes the
zone's BAND. The gap's boundary is then ONE closed surface of three
discrete pieces — the interior shell, the pre-meshed CAP (an annulus in
the wall plane: rim ring verbatim, the band outline as its HOLE, via the
2-D fill), and the interior part of the skin, rim-matched to the cap's
hole. The band's triangles become boundary faces carrying BOTH the zone's
skin label and the wall's own labels; the cap's annulus is relabelled the
same way, full closures, counts gated.

The volume carve gains open_wall (victims may lie in the open wall's
plane; the shell may open there via the shared _closed_shell_3d);
box-edge bands and multi-wall contact refuse with the reason.

Measured serial and np=2,4: identical zone counts, P2 quadratic FE-exact
(5e-11..7e-11) with Dirichlet gripping the remeshed Top, full Top
coverage. Interior zones unchanged: suites 0853-0856 (45 serial) and the
placement ptests green. Band contract test added to test_0855.

Underworld development team with AI support from Claude Code

* docs: the outcropping-fault section (specify-long, the cap, the labels)

Underworld development team with AI support from Claude Code
Split-node faults: zero-thickness fault contacts in 2-D and 3-D, parallel, with interface constitutive laws
Replace the hard-coded .pixi environment path test with validation of the active hpc environment, project root, Conda prefix, and Python executable so detached environments on scratch are accepted safely.

Prioritize the Pixi C++ runtime during Gadi configure and petsc4py builds for NumPy 2 compatibility, derive build concurrency from PBS_NCPUS instead of oversubscribing with 40 jobs, and update versioned PETSc architecture diagnostics.

Also replace legacy which calls and resolve ShellCheck declaration warnings.
* Update adapt vs ref24 cost script for current APIs

* Restore ref24 comparison free-slip and warm-start timing
* Update anisotropic convection demo for current APIs

* Restore free-slip upper boundary in anisotropic convection demo
…ndent pinning, explicit-GAMG opt-out, the #492 CI disarm (#530)

* Make the pinned-band emptiness test collective, and create the label on every rank

label_interface_band raised rank-locally when its own subdomain held no
band cell. A rank the surface never enters legitimately has an empty
local band — only a GLOBALLY empty band is a user error. With the
rank-local raise, a corner-confined surface at np=4 left three ranks
raising while the fourth entered the collective mover and hung to the
300 s timeout (measured 2026-08-06, review of PR #488).

Now the pinned count is allreduced, the error is raised on every rank
only when the band is globally empty, and the DMLabel is created on
every rank — including band-less ones — so downstream consumers see a
symmetric label.

Underworld development team with AI support from Claude Code

* Make the pinned band partition-independent: synchronise pinned vertices by coordinate

The straddle test and the halo-ring growth both walk rank-local cells,
and cells are partitioned disjointly, so a shared vertex whose cut (or
ring) cell lives on the neighbour rank was pinned there but not on the
owner. The mover then moved the owned copy and the neighbour's pinned
copy followed through the SF: measured at np=4 (review of PR #488,
2026-08-06), two pinned leaves moved 4.2e-3 and 1.9e-3 while np=2/3
passed on partition luck. The pinned set is now synchronised across
ranks by rounded coordinate after the core band and after each halo
ring.

The parallel test gains two guards for the band-less rank np=4 creates:
getStratumIS on a strata-less DMLabel is a segfault (#291), and max()
of a zero-size moved array raises rank-locally and desyncs the
collectives that follow.

Underworld development team with AI support from Claude Code

* An explicit GAMG choice opts out of the custom-P multigrid pickup, without crashing

build_transfers now stands down — returning (None, None), never bare
None — when the solver carries an explicit preconditioner="gamg" or a
latched _pc_user_override (the user wrote their own pc_type into
petsc_options). Before this gate, the opportunistic mesh-owned pickup
silently clobbered the explicit choice back to the custom-P PCMG at
solve time: both arms of test_0842's fmg-vs-gamg comparison ran
pc_type=mg, so a user could not opt out and the comparison was vacuous.

Both call sites (auto_inject_custom_mg and the rotated free-slip
_build_rotated_custom_Pl) guard the unpack so a "no hierarchy" answer
degrades to the default preconditioner instead of a TypeError mid-solve
— a bare return inside this gate is exactly what the #488 x #471 merge
once shipped.

Underworld development team with AI support from Claude Code

* Disarm the #492 trap in test_0842 and make the fmg-vs-gamg comparison real

Both MeshVariables are created before any solver runs: creating one
after a solve rebuilds mesh.dm under the custom-MG coarse/fine links
(issue #492), and that dangling reference is what segfaulted Linux CI
downstream of this test.

Each arm now asserts the PC type it actually ran (mg vs gamg), so the
comparison can never silently become FMG-vs-itself again. The tolerance
pair (ksp_rtol 1e-9, nodal bound 1e-7) is chosen to catch the
once-shipped true-residual stall signature; the geometric bundle now
owns its fgmres outer pairing (#514/#515), so the fmg arm's rtol is
enforced in the true residual norm.

Underworld development team with AI support from Claude Code

* CI crashes print a Python stack: PYTHONFAULTHANDLER=1 in scripts/test.sh

Underworld development team with AI support from Claude Code
…p, and a gmsh finalize cascade (#533)

* Fix inverted inside test for parallel edges so crossing patches are detected on every LAPACK build

crossing_segment missed genuine X crossings whenever a polygon edge was
EXACTLY parallel to the plane-plane intersection line: the parallel-edge
branch of _line_clip_interval returned "outside" for num < 0, but for an
interior point num = nrm.(A - p0) is negative. CI's OpenBLAS returns
exact zeros for axis-aligned plane bases, so den == 0.0 hit the inverted
branch and the 0851 fault-network tests refused with a bogus near-miss
(and the BoxInternalPatch crossing pre-check let a raw tetgen PLC error
escape); macOS Accelerate left ~1e-17 jitter in den and took the correct
division branch by luck, which is why the tests only failed on CI.

Adds a deterministic regression test with axis-aligned patches (den is
exactly zero on every platform): a genuine crossing must be found, and a
parallel-edge non-crossing must still return None.

Underworld development team with AI support from Claude Code

* Finalize the gmsh session even when the 3-D mesher fails, so one bad mesh cannot poison every later one

BoxInternalPatch called gmsh.finalize() only on success; an escaped
mesher exception (e.g. a tetgen PLC error on a crossing patch set) left
the session initialized with the failed model, and the NEXT mesh
constructor in the same process then wrote an invalid .msh that PETSc
rejects with error 79 ("expecting $Nodes"). This is exactly how the CI
crossing-detection miss in test_0851_fault_network_3d took down the
unrelated test_0851_std_reduction_method mesh build two tests later.
generate/write now run under try/finally. TODO(BUG) notes the remaining
fault-session follow-up: patch sets the crossing pre-check cannot
classify (coplanar overlap, edge touching) can still reach tetgen raw.

Underworld development team with AI support from Claude Code
…, and 56 orphaned test files rejoined to CI (#504) (#532)

* EnhancedMeshVariable.clone was uncallable either way — forward name/varsymbol (#498)

The enhanced wrapper declared clone(self) and forwarded nothing, while the
base MeshVariable.clone requires (name, varsymbol). Passing the arguments
every in-tree caller uses raised TypeError, and the zero-argument form the
wrapper offered would have failed inside the base — so clone() could not
be called at all, and six shipped examples aborted on it.

clone(name, varsymbol) now mirrors the base signature and returns an
EnhancedMeshVariable (same mesh/shape/type/degree/continuity, independent
data), so chained clones and enhanced features keep working. Regression
test covers each documented argument, clone-of-clone, data independence,
and discontinuous spaces; all four failed with the reported TypeError
before the fix.

Fixes #498

Underworld development team with AI support from Claude Code

* timing.print_table: accept the legacy keywords 18 examples still pass (#499)

The move to the PETSc-log backend cut print_table down to
(filename, format), but 18 shipped examples still call it with the old
display_fraction / group_by / output_file keywords and aborted with
TypeError on the first timing report.

output_file is restored as a working alias for filename (same behaviour:
the table is written to that file; passing both is an error). The PETSc
log backend cannot honour display_fraction (no row culling) or group_by
(events, not lines/routines), so those are accepted with a visible
FutureWarning rather than raising or being silently swallowed.

Regression tests cover the exact example call patterns, that output_file
really writes, the filename/output_file conflict, and that the current
(filename, format) interface is unchanged; four of them failed with the
reported TypeError before the fix.

Fixes #499

Underworld development team with AI support from Claude Code

* test.sh: run the rotated free-slip suite and other orphaned batches in CI (#504)

The serial batch globs skipped test_1018_rotated_freeslip.py entirely, so
the 19-test rotated free-slip suite (including the #497 regression guard)
never ran in CI. The one-off audit the issue asked for found 56 orphaned
files in total.

Batches now cover test_101*/test_102* (rotated free-slip, stokes MG,
boundary flux), test_105* (VE/VEP, ddt, yield), test_005[1-9]*/test_006[0-1]*
(mesh/deform/callback utilities), test_02* (solver smoke), test_1120*/
test_1450*, and the six named (un-numbered) test files - every added batch
verified green locally with the exact test.sh pytest invocation.

test_106* and test_107* stay out deliberately: they carry level_2/level_3 +
slow + tier_b/tier_c suites and need a triage decision before gating CI
(noted in the script). Also fixed in passing: the inline comment on the
test_1110 line swallowed its "|| status=1", so annulus adv-diff failures
could never fail the script.

Fixes #504

Underworld development team with AI support from Claude Code
…le-field FMG unlock, and multiplier_schur_pc verified (#478 #483 #484 #486) (#534)

* Make every preconditioner fallback readable: solver.pc_fallbacks records what degraded, where, and why (#484)

Ten of the twelve places a solver silently degrades its preconditioner
(the single-field FMG gate, missing hierarchy, transfer-build failures,
guard skips, forced Galerkin, the rotated-path substitutions) left no
queryable state — a warning at best, nothing at worst — so neither a
user nor a test could ask "did I get what I asked for?".

Every site now writes through one recorder (_record_pc_fallback) into a
public read-only property, solver.pc_fallbacks, with a fixed reason
vocabulary: unavailable / declined / build_failed / check_skipped /
forced. The record is cleared whenever the preconditioner options
re-resolve (the same staleness rule as _pc_resolved), and solve-time
sites (custom_mg, rotated_bc) re-record each solve. Warnings are
unchanged where they exist; the silent "auto" declines gain a record
but deliberately NO new warning. rotated_bc mirrors its two degraded
arms into the same record; its own ctx keys stay authoritative.

Behaviourally inert: no option value, route, or default changes.

tests/test_1022_pc_fallback_observability.py: every probe proven to
fire and proven silent on a clean solve (empty record is the global
negative control).

Underworld development team with AI support from Claude Code

* Honour user-set snes_rtol/ksp_atol on the saddle-point solvers, and derive tolerance margins through one documented mechanism (#483)

The Stokes solve re-pushed snes_rtol and ksp_atol before every solve
(_reassert_outer_tolerances), silently discarding a value the user set
explicitly — the worst reachability middle ground: documented as
settable, actually owned. The re-push now runs through the same
recorded-ownership latch that made snes_max_it reachable (ruling D18),
generalised to a dict (_resolve_owned_option/_push_owned_option, with
_resolve_snes_max_it kept as a named delegate): the framework keeps
asserting the tolerance-derived values until the user sets a key, after
which their value is honoured across solves. A user who never touches
the keys sees byte-identical behaviour (proved by the no-override arms).

Second half: Stokes and Stokes_Constrained derived different option
keys from `tolerance` through two unrelated hand-rolled setters. Both
now apply a per-class table (_TOLERANCE_DERIVED_KEYS, the base table
keeping its historical _INNER_RTOL_MARGIN name as an alias) through one
base method (_derive_tolerance_margins), at SET time only. The
difference in table contents is deliberate and now documented: the base
class derives the inner fieldsplit margins; Constrained derives the
outer ksp_rtol and the Eisenstat-Walker pins, because EW pinning owns
its outer accuracy. Constrained.__init__'s duplicate EW writes route
through the same helper. Both `tolerance` docstrings now state the
ownership table (OWNED vs DERIVED-at-set-time keys, and the EW caveat
on ksp_rtol).

tests/test_1023_saddle_tolerance_ownership.py reads the LIVE PETSc
objects after two solves (the second solve is the one that used to
clobber), with no-override negative controls on both classes.
Stakeholder tests that write these keys (0820, 1013, 1014x2) pass.

Underworld development team with AI support from Claude Code

* Honour explicit geometric multigrid on single-field solvers via injection-free custom-P transfers (#478)

preconditioner="fmg" on a scalar/vector solver was declined to GAMG
with a warning, because the NATIVE geometric-FMG path needs
DMCreateInjection, which PETSc cannot reliably build for a single-field
discretisation on a refined DMPlex (err62, #276). That gate locked
geometric MG out of every scalar/vector solver even though the robust
custom-P route (own prolongations + Galerkin RAP, no injection
anywhere) was already in utilities.custom_mg.

The explicit request is now honoured: the gate flags the reroute
(_pc_single_field_geo_requested) and custom_mg.build_transfers grows a
THIRD hierarchy source — requested-native, wrapping the mesh's own
dm_hierarchy tail in _DMLevelView adapters — resolved after solver-set
and mesh-owned so build_transfers stays the single "which hierarchy"
owner (#471, composing with #530's explicit-gamg opt-out, which is
untouched). Installation rides the existing auto_inject_custom_mg solve
hooks and the shared multigrid_options bundle (#468/#515), so the
options DB deliberately keeps GAMG as the safe degrade base until the
live PC is configured at first solve. Build failure degrades to that
base through the recorded barycentric -> RBF -> default ladder (#484).
Contract line, stated in both docstrings: preconditioner="fmg" is a
PREFERENCE (degrades readably); set_custom_fmg is a DEMAND (raises).

"auto" is deliberately unchanged — flipping the single-field default is
its own validation campaign per #478 — it keeps GAMG and records the
decline as the migration probe.

Found en route: a gmsh-imported BASE hierarchy level carries
section-only coordinates (PetscContainer, no PetscFE), and
DMCreateInterpolation from such a source silently returns a ZERO
matrix; _DMLevelView therefore works on a clone and installs a P1
Lagrange coordinate FE when missing.

tests/test_1020 becomes the unlock test (live PC "mg" over every
hierarchy level on the exact #276 err62 geometries; degrade arm proven
by monkeypatched builders; auto-unchanged arm; Stokes velocity block
untouched; DB-vs-live honesty arm) and
tests/parallel/ptest_1020_fmg_single_field_parallel.py proves route
parity at np=2.

Underworld development team with AI support from Claude Code

* multiplier_schur_pc: prove where it is live, instrument where it is inert, correct the drifted docstrings (#486)

The issue measured four identical convergence rows across two decades
of viscosity contrast and asked "verify or remove". Tracing PETSc's
fieldsplit.c settles it without ambiguity: the flag swaps only the
Pmat (h,h) block, and under Stokes_Constrained's own defaults (selfp +
diag_use_amat) selfp assembles the Schur preconditioner from AMAT
sub-blocks — the swapped block is provably never read, which is exactly
what the issue measured. The flag IS read under
pc_fieldsplit_schur_precondition='a11' (Sp = the grouped [p,h] Pmat
block) and under a monolithic direct factorisation of the Pmat.
Verdict: keep + instrument, not remove.

Instrumentation: at the hh_pc selection in _setup_solver, an explicit
opt-in that cannot reach the PC (not a11, diag_use_amat set, not a
direct solve) records reason='declined' in pc_fallbacks AND warns —
an explicit opt-in silently doing nothing is exactly the #477 class.
Docstrings: multiplier_schur_pc now states its two live regimes and
the inert one; the Constrained saddle_preconditioner claim that selfp
uses "the 1/viscosity mass from constitutive_model.K" was drifted
(selfp reads the Amat; the 1/mu mass participates only under a11) —
corrected, with a TODO(BUG) on whether selfp should see Pmat blocks at
all (a design question, deliberately not changed here).

tests/test_1024_multiplier_schur_pc.py: matrix-level oracle (assembled
Schur pre differs flag-on/off under a11 — immune to "both converge in
4 iterations"), the inertness codified as an exact-equality control
pinning the PETSc semantics, regime-aware probe silence, no-flag
silence, and the setter re-registration contract.

Underworld development team with AI support from Claude Code

* Review response: a resolution-cached hierarchy yields to a new explicit choice; the inertness probe reads the flag's value

Two defects from the #534 adversarial review, both violations of the
bundle's own observability contract. (1) The explicit-fmg install cached
its hierarchy in solver._custom_mg, which auto_inject re-installs
unconditionally — so a later preconditioner="gamg" was unreachable with
an EMPTY pc_fallbacks record. The cache is now marked auto_cached and the
preconditioner setter drops it; a set_custom_fmg registration is a demand
and is kept. Regression test with a verified negative control (fails with
the marker disabled). (2) The multiplier_schur_pc inertness probe read
hasName("pc_fieldsplit_diag_use_amat") — the key's presence — so
diag_use_amat=false (the flag LIVE, Schur pre measured differing by
rel-Frobenius 0.30) still warned "no effect" and recorded a decline. It
now reads the bool value; regression test pins the flag=false silence.

Underworld development team with AI support from Claude Code
…scales on K (#463) (#535)

* A yielding TI-VEP fault looked unyielded in every diagnostic: .viscosity now reports the yield-limited eta_1 the stress uses (#463)

TransverseIsotropicVEPFlowModel.viscosity computed the yield-limited
fault-plane viscosity and then returned the un-yielded bulk eta_0 — the
computed value was a dead local. The flux was always correct (the rank-4
tensor applies the yield via _eta_for_tensor), but renders, projections
and anything else reading .viscosity saw no yielding at all.

The property now delegates to _eta_for_tensor with the ACTIVE integrator
mode, so it returns exactly the coefficient the stress tensor is built
from: under yield, the persistent _eta1_yield_eff container from #493
(a wrapped atom, so the Picard freezing contract holds for anything
that bakes the property). plastic_fraction had the same drift squared
(bulk-vs-fault comparison that Max()ed to zero, plus a misplaced
ternary) and now reports 1 - eta_1_eff/eta_1_ve on the weak-plane
channel. K stays shear_viscosity_0, deliberately.

The two Nitsche boundary kernels used .viscosity as their PENALTY scale;
that consumer wants the stiffness scale, so they now read .K — which is
identical to their old behaviour for every model (isotropic: K ==
viscosity; TI: K == eta_0 == the old .viscosity return) and immune to
the weak-plane value collapsing at yield.

New test (validated fail-before on the pre-fix build):
test_ti_vep_viscosity_property_reports_yield_limited_eta1 — property is
the container _eta_for_tensor bakes, frozen under d/dL with a Newton-
unwrap positive control, and reports the unlimited VE fault-plane value
when yield is disabled (negative control).

Underworld development team with AI support from Claude Code

* Isotropic VEP now bakes its effective viscosity as a wrapped atom, like every other yielding model (the tidy deferred on #493)

ViscoElasticPlasticFlowModel._build_c_tensor baked the UNWRAPPED .sym
contents of the effective viscosity into the c-tensor. The tangent
happened to be frozen anyway (the strain-rate content sat inside wrapped
atoms one level down), but the class was the one yielding model not
following the wrapped-atom freezing contract from #457/#493.

The viscosity property now stores the combined (yield-limited, floored)
coefficient inside a persistent _vep_eff_viscosity container and returns
it — the ViscoPlasticFlowModel._plastic_eff_viscosity pattern — and the
_build_c_tensor override is deleted so the base ViscousFlowModel build
bakes the wrapped atom (taking two stray debug prints with it). The
property now always returns a UWexpression, so _object_viewer no longer
breaks on a yielding VEP model.

Proof nothing changes: the new frozenness pin
(test_vep_c_tensor_coefficients_are_frozen, with a Newton-unwrap
positive control) passes on both sides of the change, and a 6-step
shear-box loading sequence through yield reruns identically to 3.2e-10
in v, p and the tau_xy trace (solver tolerance 1e-6; the TI-VEP twin
of the same check is bitwise identical).

Underworld development team with AI support from Claude Code
…shVariable rebuild (#492) (#536)

* Release, don't destroy, the old DM and vectors in the MeshVariable rebuild (#492)

Creating a variable on a mesh that already has fields rebuilds mesh.dm;
the old DM and vectors were eagerly destroy()-ed. petsc4py destroy()
zeroes the handle of the wrapper object itself, and mesh.dm / var.vec
hand out that same wrapper, so any user-captured handle was blinded --
the next call on it dereferenced a NULL handle (SIGSEGV on optimized
PETSc, issue #492). The freed vector pages also left user-held numpy
.data views silently reading and writing freed memory, the delayed
heap-corruption crash behind the PR #488 Linux CI segfault.

Drop the references instead and let PETSc refcounting free each object
with its last holder: captured handles stay valid (stale), and memory
behaviour is unchanged when nobody holds them -- measured RSS over
repeated rebuild+solve cycles is identical with and without the eager
destroy (solver-side holders are clones of mesh.dm, never mesh.dm).
The rebuild's restore loop now also clears _data_cache/_array_cache
alongside _canonical_data (matching the mesh.adapt path) so UW3 never
hands back a view of the released vectors. mesh._lvec release composes
with the existing lazy rebuild in update_lvec().

Underworld development team with AI support from Claude Code

* Regression tests for the DM-rebuild handle contract (#492)

test_0858_dm_rebuild_lifecycle.py: the issue reproducer (a captured
mesh.dm handle stays valid and the mesh solves on the rebuilt DM),
refcount hygiene with a 20-cycle RSS bound (7.1 MB measured, 50 MB
bound), .data view refresh after rebuild, and the test_0842-shaped
adapt-child arm/solve/teardown sequence from the CI detonation story.
The pre-fix SIGSEGV (design-probe subprocess exit -11) is cited in the
docstring rather than executed; the observable asserts fail cleanly on
the unfixed build (held handle zeroed) -- verified before the fix.

ptest_0011_dm_rebuild_held_handles.py + mpi_runner.sh entries: the same
contract on every rank; verified at np2 and np4.

Underworld development team with AI support from Claude Code

* docs: data-access contract for views and handles across a DM rebuild (#492)

State the rule explicitly in the governing document: raw numpy views of
variable data do not survive creating another variable on the same mesh
and must be re-read (UW3 invalidates every cache it hands out; views
captured by user code cannot be reached). Captured PETSc handles
(mesh.dm, var.vec) get the gentler post-#492 contract: valid but stale.

Underworld development team with AI support from Claude Code

* Review hardenings: an allocator-proof view assert, and the FMG chain on the adapt child

Two test robustness items from the #536 review. np.shares_memory on the
refreshed view was allocator-dependent - the released buffer is freed
before its identically-sized replacement allocates, so glibc block
recycling could fail the assert spuriously on exactly the platform it
guards; object identity plus value correctness assert the same contract
without dereferencing the dangling view. And the adapt-child arm now
solves its first pass under preconditioner="fmg" so the custom-P coarse
chain from the original detonation sequence is genuinely constructed -
under "auto" a single-field solver declines to GAMG and the chain was
never built.

Underworld development team with AI support from Claude Code
Some Azure runners advertise an InfiniBand verbs device (mana_0) that UCX
cannot open; MPI_Init aborts inside the import smoke test before any
underworld3 code runs. Four hits in a week across #466/#526/#527 (issues
#461/#528), each fixed by re-rolling the runner lottery. A single-node CI
runner needs only shared memory and TCP, so pin the transports at the
workflow level - it covers the smoke test and every pytest batch alike.

Underworld development team with AI support from Claude Code
…current; plasticity-solvers rewritten (#454) (#489)

* skills: parallel adapt engines, band pinning, and FMG on adapt children

adapt-on-top-faults
  - engines section: nvb vs edge_split, both parallel in 2-D and 3-D and
    bit-confluent; edge_split has no conforming closure so refinement cannot
    escape the marked region, and marks on the DIAMETER (the volume proxy
    reported the target met while the mesh was 3.2x coarser across the feature).
  - repair=True: gates on reducing the largest angle, NOT on Delaunay. Delaunay
    maximises the minimum angle while P1 depends on the maximum, and flipping a
    gmsh mesh toward Delaunay raised the 99th-percentile max angle. Worth it on a
    poor base (156 -> 115 degrees, slivers 3.84% -> 0.00%), marginal on a clean
    one, and it gives up bit-confluence, so it is opt-in.
  - relax on a mesh refined onto an interface makes things WORSE (+77% leak);
    pin_bands is the fix.
  - new section on sizing the band and representing the fault margin: the
    -2 Cov(eta, edot) leak metric, why a within-cell marking rule loses to the
    plain distance size field, why the optimal band width depends on which
    objective you pick, and what a step-edged margin buys and costs.
  - gotchas: Mesh(dm) takes the DM over (bare SIGSEGV if you keep using the old
    handle); Mesh(dm) without boundaries= loses the boundary enum; evaluate()
    "Total components 8 != 6" on a variable-heavy mesh.

adaptive-meshing
  - PIN THE INTERFACE section for relax(pin_bands=...), including the
    signed-vs-unsigned distance rule and the pinned_labels merge trap.
  - cross-reference to nonlinear-solver for the FMG setup.

nonlinear-solver
  - new section: FMG on an adapt-on-top child. The child carries its own graded
    custom-P tail and solvers pick it up automatically; the base must have
    refinement>=1; a base-only tail triples the V-cycle count; V-cycle counts are
    insensitive to element quality (a pass, not a failed measurement) so use GAMG
    as the quality probe; relax can trip #424 into the dense RBF fallback;
    repair invalidates the any-degree transfer but not the vertex prolongation.
  - cross-references to adapt-on-top-faults and adaptive-meshing.

Underworld development team with AI support from Claude Code

* skills: bring the two adapt/solver skills current with development

adapt-on-top-faults: the rotated free-slip path now picks up an adapt
child's mesh-owned MG tail automatically (custom_mg.build_transfers,
the #467 fix) — the "FUNDAMENTAL, not a quick fix" gotcha row was
describing a bug that is gone. Point the band-sizing section at the
interface-alignment primitives that now exist (place_sheet /
place_thin_volume / remove_embedded, #517-#526).

nonlinear-solver: three stale claims corrected — rotated free-slip is
no longer the exception to the automatic tail pickup (#467); the adapt
tail is one MG level per DOUBLING of h, not per refinement generation
(mg_coarsening_ratio=2.0, #515); refinement=0 still yields a hierarchy
that starts at the base, so "no coarse grid" overstated it. Three
capabilities that landed since the branch: preconditioner="gamg" is
respected on adapt children (#530), single-field FMG (#478/#534), and
solver.pc_fallbacks as the observability hook (#534). The supersession
note no longer names the removed in-SNES ramp API.

Underworld development team with AI support from Claude Code

* skills: rewrite plasticity-solvers around what the yield campaigns measured

Fixes #454. The skill taught the retired yield-homotopy doctrine in
~8 places, headlined by an enable method that no longer exists in src/
and an in-SNES delta-ramp that is separately proven to diverge. The
doctrine rested on a unit-scaling error: re-measured on the corrected
problem, the delta-march never succeeded where a direct hard-Min solve
failed, and the ruling was to regularise the problem, not the solver.

Rewritten around the evidence: Newton with the automatic Picard entry
(Picard is an entry requirement, not an accelerator), rescue on failure
OR stagnation, grid sequencing as the validated warm start, the per-model
tangent table, and the #475 yield_mode / yield_smoother / yield_anchor
substrate presented as a modelling choice with the multi-solve-only
discipline for any delta march. yield_continuation is described honestly
per open issue #473 (the cold-start guarantee does not hold on a
Piecewise yield stress; the step control is effectively one-shot).
Floors updated to the post-#475 semantics (viscosity_min_rounding).
Kept: the Newton-confirmation check, the VEP-indefinite ruling, the
SNESFAS ruling, the Picard-folklore footnote.

Underworld development team with AI support from Claude Code

* The nonlinear-solver recipe demotes the delta-march to a rescue

The recipe's step 2 still presented solve(homotopy=True) as the default
entry point ("one call - automatic"), which is the retracted doctrine in
its post-API form: the evidence that recommended a delta-march first
rested on a unit-scaling error, and the driver's cold-start guarantee is
broken (issue #473). The step now escalates honestly - grid sequencing
first, the delta-continuation as rescue of last resort with the #473
caveat stated - and the Layer-2 status note carries the same demotion.
The delta-discipline itself (constant per solve, never in-SNES) is
unchanged; plasticity-solvers holds the ruling and evidence.

Underworld development team with AI support from Claude Code
…de coordinates, and the consistent line mass (#459) (#537)

* 2D boundary flux: give every trace node its own coordinate — degree-3 recovery was silently wrong (#459)

A degree-3 trace carries TWO interpolation nodes per boundary edge point,
but the node gather emitted one entry per DMPlex point with the point's
single (midpoint) coordinate: one of the two edge-interior reactions was
never read, the coordinate keys collapsed, and the de-smear proceeded
down the P2 path — returning 0.57-0.74 of an exact unit flux from 17 of
the 25 trace nodes, with no error raised.

The fix keys by interpolation node, not by point:

* _boundary_field_nodes emits (point, slot, coord) per NODE; the true
  edge-interior node coordinates come from interpolating the mesh
  coordinate field into a matching-degree space (_trace_interior_coords,
  the same createDefault/node_endpoints=False construction used for
  every UW3 field FE, so the per-point slot ordering matches the field
  section by construction). The build is collective; whether it is
  needed is agreed by allreduce so empty ranks participate.
* boundary_flux reads the reaction per (point, slot) — the second P3
  edge dof is no longer dropped; vector reactions read node-major.
* _desmear derives the trace order structurally from the field section
  (no more midpoint-key sniffing) and assembles a general Lagrange line
  mass from the measured node parameters; P1/P2 keep their exact
  hardcoded matrices byte-for-byte.
* mass="auto" now selects the consistent solve for 2D traces of degree
  >= 3: PETSc places the interior nodes asymmetrically (Gauss-Jacobi),
  where row-sum lumping is only O(h) pointwise (measured 1.6e-2 flat
  error at res 8 for a linear flux, vs 5.8e-4 consistent). P1/P2 keep
  lumped, which is exact for linear flux on their symmetric layouts.
* The de-smear now REFUSES a key collapse exactly (len(nodeR) vs
  len(xs), both 2D and 3D branches) instead of silently overwriting —
  the cheap detection the issue asked for, kept as a guard.

Measured on 8x8 unit-flux conduction (T = 1 - y, exact at every degree):
degree 1: 8.0e-5, degree 2: 1.2e-4 (both unchanged, bit-identical),
degree 3: 4.3e-1 before -> 3.1e-4 after, 25/25 nodes reporting.

The 3D path is untouched: P3 traces there were already rejected legibly
(facet/edge dof checks) and still are; the #469 mass="p1" mode is
unchanged.

Underworld development team with AI support from Claude Code

* Tests for #459: degree-sweep oracle, interior-node placement, collapse negative control, np2 arm

* test_boundary_flux_degree_sweep_2d: T = 1 - y unit flux at trace
  degrees 1/2/3 — every wall node exact, every trace node reporting
  (fail-before validated: degree 3 read 0.57-0.74 of the unit flux from
  17 of 25 nodes on the unfixed build; degrees 1-2 passed).
* test_boundary_flux_p3_interior_node_placement: T = xy gives a flux
  linear in x, so a slot swap or midpoint-collapsed node placement
  displaces the recovery by ~2e-2 — 36x the passing mid-wall error.
  The constant-flux oracle alone cannot see a swap (both edge-interior
  reactions are equal under uniform flux).
* test_boundary_flux_p3_collapse_guard: negative control — force the
  pre-fix collapse (both edge-interior nodes keyed by the edge midpoint)
  and the de-smear must refuse with the #459 RuntimeError, proving the
  guard fires rather than silently overwriting.
* parallel/test_1065: degree-3 unit-flux arm at np >= 2 — the per-node
  coordinate build is collective, so ranks owning none of the flux
  boundary must still participate (passes np2 and np4).

Underworld development team with AI support from Claude Code
place_thin_volume gains assembly={"fuse","fragment"}, defaulting to
"fuse". Where two zones overlap, fragment cuts along the boundary of the
overlap; for a tangential merge — a ribbon soling into another, the
listric case — that boundary is a lens closing at the convergence angle,
and the mesher resolves it as a chain of slivers. Measured on the sole
geometry now in test_0855: minimum angle 0 degrees and 22 cells under 5,
against 37 degrees and none for the fused union.

Nothing downstream needs those internal boundaries. The zone carries one
label, and a cell's fault properties are read from the Surface objects by
proximity, not from the CAD piece it was meshed in — so the seams are
conditioning cost with no physics on them.

The same swap applies in 3-D (_occ_assembly_3d) and is the default there
too. The CAD area/volume gate is unaffected: it is computed from the
faces that survive the boolean, which are the union either way, and
matches the meshed area to 1e-16 relative under both.

The fragment branch is kept and tested as the negative control: a test
that only asserted the fused mesh is clean could not tell whether the
geometry still exercises the defect.

Underworld development team with AI support from Claude Code
The plan for this work lived outside the repository. The architecture
belongs in docs/developer/design/ where collaborators can read it: the two
representations and what separates them, the tip-pinning mechanism behind
the seam defect, the fuse ruling landed here, and the geometric slice
criterion that is proposed and not yet built.

Marked throughout as to what is implemented and what is not, and it
records two things that are decided but not obvious: the CAD area gate did
not need the change the plan expected, and the question of what owns a
cell where two zones overlap is still open — contacts refuse to overlap
and pay a gap, zones overlap freely and resolve ownership by nearest
surface vertex, which is neither continuous nor independent of how the
traces were sampled.

Underworld development team with AI support from Claude Code
… with a constants-aware key (#417, supersedes #418) (#543)

* Reuse the rotated free-slip solver workspace across solves (#417)

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

* Close the rotated-workspace blind spot: rampable constants join the key, 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

* Fault contact composes with the rotated workspace cache: pair blocks 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

* Review response: persist the operator key only for values Ahat holds; 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

---------

Co-authored-by: Tyagi <gthyagi@gmail.com>
Four settings on one mesh, measured. Keeping the weak zone under the
contacts reproduces the pure-zone answer to 99% and solves about three
times faster; stripping the zone back to the handover buys a further
factor of two and loses a quarter of the slip on the fault that receives
through the merge.

Those are the same mechanism. With the zone intact a contact tip ends
inside weak material and is never pinned; stripped, it ends at the weak
material's edge, which is the abutting case measured at 74.6% on the
straight-fault control. So the criterion only has to be geometrically
right for the stripped variant. With the zone kept, slicing less is
slower, slicing more is faster, and slicing past what the split machinery
supports is an explicit refusal — never a wrong answer.

Also records a negative result, so it is not re-derived: the three-times
speedup is NOT contrast-driven conditioning relief. It is flat across
three decades of viscosity contrast and the zone-only cost barely moves,
because transverse isotropy had already removed that penalty. The reason
is still open, and the rotated path's iteration counters read zero, which
is what has to be fixed to settle it.

Underworld development team with AI support from Claude Code
Two defects, both of which made a converged rotated solve report that it
had not converged.

The direct path pins one pressure DOF to fix the gauge — the row is
replaced by the identity and the rhs zeroed — but the residual kept
counting that row. The loop therefore measured itself against a floor it
had itself defined as unreachable, and ran to max_it. Measured on a fault
contact problem: the velocity residual reached 6e-12 at the FIRST
increment and the entire remaining |F| was the pinned DOF alone,
bit-identical for eight further iterations that changed nothing (the
velocity residual was exactly 0.0 from the second on). The pin is now
decided once, before the residual closure is built, and excluded from the
residual for the same reason normal_rows are: it is not an equation the
solve is trying to satisfy, so it is not evidence about convergence.

Separately, the loop never published a reason on the SNES. Every SNES call
inside it evaluates one residual or tangent, so the SNES's own reason
describes a linear step and stays 0 for the solve as a whole — and
petsc_generic_snes_solvers decides convergence from
`snes.getConvergedReason() > 0`. Every rotated solve was therefore
reported as unconverged, including ones that converged at rel 1e-8. The
loop now sets FNORM_RELATIVE / SNORM_RELATIVE on a clean exit and
DIVERGED_MAX_IT / DIVERGED_LINE_SEARCH otherwise.

The regression test covers both paths, because the pin exists only in the
direct one; it fails on both before this change.

Underworld development team with AI support from Claude Code
The linear (n=1) plane-strain solution for a fault terminating inside a
viscous medium, from the Appendix of Barr & Houseman 1996 (GJI 125,
473-490). Implementation follows that of @gthyagi, who has been using
this solution for fault benchmarking.

Why it is worth having: every fault result we have is currently measured
against another discretisation — a gmsh-union ribbon control — so there
is no absolute standard. This is one, and it has a fault in it: an
internal boundary carrying zero shear traction, continuous normal
velocity and continuous normal stress, with the tip inside the medium.

The structure is the interesting part. In polar coordinates about the
tip, the stream function separates into a Fourier series in m = q/2:
whole-integer m is continuous deformation, half-integer m IS the fault
discontinuity, and boundedness at r=0 admits only one negative index,
m = -1/2. That single mode carries the whole singularity, which is why
slip goes as sqrt(r) and stress as 1/sqrt(r). The exponents are a
property of the fault's own Fourier mode rather than an assumption, and
the test asserting the slip is carried entirely by that mode says so.

The tests verify the field IS a Stokes solution rather than comparing it
to a stored answer: div u = 0, the momentum balance vanishes identically
in both components, zero shear traction on both faces of the fault,
normal velocity continuous across it, and slip = 2 U0 sqrt(r/R0) — the
paper's own normalisation. If those hold together the transcription is
the solution, whatever a solver later does with it.

That mattered here. The half-integer sine terms of u_theta appear with
one sign in the paper's boundary datum (A8b) and the opposite sign in its
solution (A9b). Incompressibility settles it: for u_r = A sqrt(R) f and
u_theta = sqrt(R) g, div u = 0 forces g' = -(3/2) A f, which is (A8b)'s
sign. Verified by flipping it — the Stokes test fails with a divergence
of (0.75 cos(t/2) + 2.25 cos(3t/2))/sqrt(r).

Plane strain only. The paper's thin-viscous-sheet solution (A10-A13) has
non-zero in-plane divergence and is a different equation set from our
incompressible Stokes, so it is not a benchmark for this solver.

Not yet wired to a UW3 solve: the analytic domain is a disc with the
fault running from the centre to the perimeter, and 2-D faults cannot
reach a boundary yet (#549). The solution and its verification stand on
their own until that lands.

Underworld development team with AI support from Claude Code
lmoresi and others added 19 commits September 12, 2026 07:07
A guard for the test globs, and 22 session scripts out of the test tree (#721)
…quote $PYTEST

**The bands were never a cost decision.** `test_06*` was disabled as "potentially
problematic" and stayed that way; `test_106*`/`test_107*` were held back pending
"a triage/deselect decision" that nobody made. Measured 2026-09-12: those three
bands hold 243 tests, of which only 26 are level_3 or tier_c. 55 are level_1 AND
tier_a — hardened, fast, and exactly what CI exists to run. One slow test
(test_1064) was quarantining its neighbours because they shared a number.

They run now. The only failure among them was a test-side defect, fixed
separately in #728.

**This is affordable today.** The comment justifying the 120-minute cap measured
the serial phase at ~55 min on 2026-08-15, before xdist. The five successful runs
before 2026-09-12 spent 34, 35, 43, 44 and 48 minutes in "Run tests". Sharding
across parallel jobs stays the durable fix if this tightens again — referenced in
the workflow as "the CI-runtime issue" but never filed, now #730.

**Tier C reports rather than gates.** A tier C failure demands an EXPLANATION,
not a revert (Charter §8, TESTING-RELIABILITY-SYSTEM.md): these are
characterisations that can fail because the code got BETTER. They are excluded
from the batches that set $status and run in their own pass at the end, which is
read but cannot fail the build.

**$PYTEST is now an array.** It was a string expanded UNQUOTED at all 17 call
sites, which made adding any argument containing a space unsafe: bash splits
`-m 'not tier_c'` into the words `'not` and `tier_c'`, and pytest answers that by
silently collecting NOTHING and exiting 0 — a green run that tested nothing:

    $ pytest -m "'not" "level_2'" tests/test_1070_free_surface_plume.py
    no tests collected in 0.00s          # exit 0

The first version of this change dodged it through PYTEST_ADDOPTS, which pytest
shlex-splits. Quoting the variable removes the hazard instead of routing around
it, so the next argument someone adds is safe too. Verified by dry run that the
marker arrives as a single argument, `ARG[not tier_c]`, in both the xdist and
in-process branches.

**Also drops a line that now runs twice.** `test_1072` was pulled forward out of
test_107* by name because that band was not batched. The band runs as a whole
now, so the named line is redundant; it is still covered, once.

Verified by dry run with a stub pytest: every gating batch carries the exclusion
as one argument, the previously-deferred bands appear, test_1072 appears once,
and the tier C pass runs without the exclusion.

Underworld development team with AI support from Claude Code
…re DEFERRED

Rebased onto #723 so the two changes land consistently, which immediately
surfaced an integration defect the guard itself caught: `globs_run_by` matched
the literal `$PYTEST`, and this PR rewrites every call site to the array form
`"${PYTEST[@]}"`. The guard therefore found NO globs and declared all 302 test
files dark. It now matches both spellings.

**DEFERRED is empty, and that is the point.** Every band that sat there — the
test_06NN regression suite and test_106*/test_107* — is batched now. Nothing is
excluded from CI by its number any more; a test that should not gate says so
with `@pytest.mark.tier_c`, which is a property of the test rather than of where
it falls in the numbering.

**Dependencies, which the PR did not declare.** Copilot was right that this
cannot land alone:

- #728 fixes `test_1060_nitsche_freeslip`, whose bound is still `<1e-4` against
  an observed 1.0081e-4. Enabling `test_106*` without it makes this batch fail.
- #729 puts the `tier_c` mark on the `test_1070` comparison and updates the tier
  definitions in `tests/pytest.ini` and the docs. Without it this PR enables
  `test_107*` while that comparison still gates, and the reporting pass
  contradicts a marker contract that still reads "development only, not for
  automation".

Merge order is #728, #729, #723, then this.

The PYTEST_ADDOPTS point in the same review was already addressed: the array
landed in an amended commit the review had not seen. It also removes the
objection behind it — an inherited PYTEST_ADDOPTS from the caller is no longer
discarded, because the script no longer sets it.

Verified: dry run shows the marker arriving as a single `ARG[not tier_c]`, the
tier C pass running without it, and the guard passing with 302 reachable and 0
deferred.

Underworld development team with AI support from Claude Code
Every session loads this file verbatim before it loads anything else, and it had
grown to more than twice the size of the Style Charter it defers to. That is the
wrong way round: the file whose job is to point at the governing documents was
the bulkiest thing in the context, and the 2026-09 audit found it competing for
attention with the rules it exists to route to.

Most of the bulk was duplication of documents that already govern their topic.
`docs/developer/guides/branching-strategy.md` already carries the branch roles,
the API-versus-implementation discipline, the worktree lifecycle, the branch
policy and a section addressed to AI assistants; CLAUDE.md restated 4.3 KB of it.
The on-demand documentation list restated the authority map in
`docs/developer/index.md`. Both are now one pointer each.

The build constraints move to `guides/development-setup.md`, which was a stub
that still told developers to run `pixi run underworld-build` — a command the
tooling replaced with `./uw build`. It now carries the rebuild rule, the
editable-install prohibition and its recovery recipe, the PETSc
non-relocatability warning, and how worktree environments work. CLAUDE.md keeps
the four hard rules as one line each.

Nothing normative was dropped. What remains is what a session needs before it
knows where to look: the Charter mandate, session bootstrap and the external
planning protocol, the hard constraints, where files go, the rulings that are
non-obvious and easy to get wrong (rotated free-slip over Nitsche, the data
access contract, unwrap-before-atoms, the ambiguous `model`), the test
markers, and the pointer to the authority map.

Every path this file names was checked to resolve.

Underworld development team with AI support from Claude Code
… a gate

The first real CI run of the tier C pass worked — two characterisations failed,
were reported, and the build stayed green:

    2 failed, 29 passed, 3227 deselected ... in 2504.48s (0:41:44)
    ⚠️  A tier C characterisation changed. This does NOT fail the build.
    ✅ All tests passed!

But it cost **41m44s** and took the job from ~45 minutes to **1h40m** against a
120-minute cap, leaving 20 minutes of headroom. My "affordable today" estimate
covered the newly-batched bands (~22 min) and missed that the reporting pass
re-runs the slow level_3 characterisations — `test_1064` is `slow` and is most
of that 42 minutes.

Trimming the pass to `tier_c and not slow` would have fixed the clock by
dropping `test_1064` — the very test whose two-month absence concealed the
Nitsche penalty regression in #734. So the COST moves off the pull-request path
instead of the COVERAGE being cut: `--tier-c-report` is now a standalone mode
that runs only the characterisations and always exits 0, and the workflow asks
for it on a push to development rather than on every pull request.

A report does not need to be per-commit. It still runs on every merge, which is
where someone reads it.

Verified: the default run contains no tier C pass, `--tier-c-report` runs only
the characterisations and exits 0, the gating batches still carry
`ARG[not tier_c]` as one argument, and the coverage guard passes with 302
reachable, 0 deferred.

Underworld development team with AI support from Claude Code
…efinition fails on the day

Maintainer ruling (2026-09-14): a test asserts against a hard baseline, or it is
not a test. A test loose enough to absorb a change in a default does not fail
when the default changes — it drifts inside its own margin and breaks later,
somewhere else, for reasons that are hard to trace back.

#734 is the case study. #692 redefined `mesh.cell_size()`; nothing failed; the
Nitsche penalty `gamma*mu/h` moved 43%; the Zhong spherical-shell benchmark slid
from 0.2% to 2.4% — still inside its 5% tolerance — and only broke months later
on one platform's triangulation, three layers from the cause.

**The test that would have caught it on the day.** `cell_size` had two tests and
between them they missed the case that changed: one pins an exact value (sqrt(2)/8)
on a regular QUAD box, where `cell_size` and `get_min_radius` happen to agree; the
other covers simplices but checks the implementation against an independent
reading of the SAME DEFINITION, so it stays true if the definition moves on both
sides. Added:

- `cell_size` on congruent right-isosceles cells is exactly 2h/3, to 1e-12. A
  redefinition fails immediately, at the quantity that changed.
- The two measures agree on TENSOR cells (ratio 1) and differ on uniform
  SIMPLICES by exactly sqrt(2) — `cell_size` 2h/3 against `get_min_radius`
  sqrt(2)h/3. The Nitsche penalty scales with that ratio, so pinning it means
  neither measure can move silently.

Negative-controlled against the real 1.745x shift: both fail, and both name the
quantity rather than a downstream symptom.

**The docstring was false.** `add_nitsche_bc` said the two measures "coincide on
a uniform mesh". True on a regular quad box — presumably where it was checked —
and false on a uniform simplex mesh, which is what every free-slip and fault
model uses. Corrected in both the implementation comment and the user-facing
parameter docs.

**Four method-comparison assertions removed rather than relocated.** #729 moved
them to tier C; that was the wrong fix. In every case the hard baseline was
already present and the ratio was discarding it:

- `test_1070` computed an absolute error against the PRESCRIBED datum and then
  asserted `errors["strong"] < 0.5 * errors["penalty"]`. Now asserts the error
  directly. The ratio also hid which side moved — strong went 1.06e-2 to 1.73e-2
  between platforms while penalty barely changed.
- `test_0066` and `test_0773` had their baselines asserted in sibling tests
  already, so the comparisons added nothing actionable.
- `test_1060` asserted that the weak constraints must STAY inaccurate
  (`leak > 1e-5`) — an assertion that fails when the code improves. Its exact
  half, an essential BC holding v.n to machine precision, is a hard baseline and
  is now a tier B contract; the weak-path leaks are printed, not gated.

Charter §8 leads with the baseline requirement and says why: it must be tight
enough to fail the moment a default changes. TESTING-RELIABILITY-SYSTEM.md records
that C1 is rare by design — reach for it only after showing no baseline exists.

55 passed, 1 skipped across the five files.

Underworld development team with AI support from Claude Code
Stop excluding tests by band, let tier C report instead of gate, and quote $PYTEST
…eline

Hard baselines instead of drift detectors, and pin cell_size so a redefinition fails on the day
CLAUDE.md is a router, not a manual: 22.9 KB to 8.3 KB
…fuse (#753)

The JIT derives both the module name and the C symbol prefix from a hash of the
generated source, so ranks that generate different text build disjoint artefacts
and the rank-0-compiles/others-load protocol breaks. Agreement was REQUIRED, and a
mismatch raised.

It fires in practice. The lowering in generate_c_source is not deterministic
across ranks (#752), and a Stokes solve with a power-law transversely isotropic
viscosity under the consistent tangent trips it in roughly half of np=2 runs -
measured 2 of 4 on feature/discrete-adjoint. The abort lands in the forward solve.

Two measurements say adopting one rank's source is a sound repair rather than a
way of ignoring a wrong answer:

  * the sources differ ONLY in the order of factors in commutative products.
    Diffing both ranks' output on a failing run: 12 of 225 lines differ, every one
    with an identical token multiset and identical length. Every rank's source is
    a correct kernel for the same equation.
  * the solver's own symbolic blocks - constitutive tensor, flux, and every
    Jacobian block - hash IDENTICALLY across ranks on the runs that abort. What
    differs is produced inside generate_c_source, not handed to it.

So the disagreement is over which of several correct spellings to compile, and
adopting one is enough. Rank 0's is taken and every rank rehashes from it, which
restores the invariant that actually matters: one source, one hash, one module.
Measured on the reproducing branch: the repair fired in 4 of 6 runs and all 6
succeeded, against a baseline of 2 aborts in 4.

This is a REPAIR, not a fix. It is said out loud - one warning naming the issue -
rather than papering over the upstream non-determinism, which is still a bug and
still worth finding. The common path is untouched: ranks that already agree keep
their own objects and pay one allgather of a 16-character string.

Two dead ends recorded so they are not retried. The C printers do NOT use
StrPrinter._print_Mul - C89CodePrinter delegates to CodePrinter._print_Mul, which
has no stored-order fast path and does sort, and forcing that sort produces
byte-identical C while a run still aborts. And it is not commutative ordering at
all: recursively sorting every Mul/Add argument by default_sort_key still leaves
the ranks differing.

The repair is extracted as _agree_source_across_ranks so it can be tested
directly. Reproducing a real disagreement means reproducing a non-deterministic
bug at about one run in two, which is not a test - it would pass half the time
with the repair removed. The tests build the disagreement themselves, so they keep
working once the lowering is made deterministic.

Underworld development team with AI support from Claude Code
Proposal for what v3.2.0 announces and at what level, built from the 186
PRs merged since v3.1.0. 21 features rated against EU/NASA TRL: 9
Supported, 10 Preview, 2 In development.

Evidence is the 2026-09-24 validation runs: ordinary tests at 4 ranks
(587/590), the MPI suite (157/158) and the 16 genuine ptest_ files run
for the first time (99 pass, 1 hang).

Not the release manifest. For review by @lmoresi and the Steering
Committee before any manifest change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pre-read for the Steering Committee, red/amber/green TRL banding
Feature table: free surface has a design note and no runnable example,
not the reverse — the free_surface/ scripts are FSSA, which the project
rejected. Mesh adaptation is held at Preview by validation, not a
missing demo. Seven doc links pointed at pages that never use the
feature's API. Collective robustness and code generation are itemised
rather than rated, since neither is a capability a user invokes:
21 entries -> 19.
A rank-divergent test hangs the run instead of failing. Adds
--timeout=600 to both mpirun lines; step 6b now runs 2 and 4 ranks.
Claims follow the bands in docs/developer/planning/v3.2.0-feature-table.md.
Gate passes all 20 at levels 1,2,3: 683 tests, no downgrades.

fmg moves preview -> supported. v3.1.0 held it at preview pending "a
dedicated FMG tier_a benchmark"; test_1020_fmg_single_field_lockout.py
now meets that condition.

mesh-adaptation drops tests/test_0760_mesh_ot_adapt.py, which no longer
exists and was being silently ignored.
…dmap

Release notes assembled from the gate output (11 Supported, 9 Preview)
plus hand-written Highlights, Bug Fixes, New Features, Improvements,
Breaking Changes and Contributors.

Extends the committee pre-read into a full meeting pack: GitHub activity
and issue-response KPIs, the five DOI-registered publications from this
quarter, progress against the nine features agreed in June, and the full
TRL 1-9 scale.

Corrects the swarm proxy speedup, quoted as "17-80x" from the PR title.
The benchmark measures two operations, not a range: 77x particle-to-node
and 17x node-to-particle.
…otes

Committee sign-off now precedes external adoption, inverting the EU
ordering deliberately — sign-off is what authorises outside groups to
depend on a capability. Noted in the feature table.

Band labels in the committee pack now use the release notes' own terms
(Supported, Preview, In development) instead of a parallel colour
vocabulary.
constitutive-models and transport-histories each bundled a mature
capability with a new one, so both announced at their weakest part.
Split per Steering Committee, 2026-09-29:

  constitutive-models          preview -> supported
  transverse-isotropy          new, preview (accuracy anchor +1.8%)
  transport-histories          preview -> supported
  integration-point-variables  new, preview (no user-facing docs)

Also swaps TRL 7 and 8 — committee sign-off now precedes external
adoption — and corrects the Gamma_P1 breaking change, which said a
still-present deprecated property had been removed.

Gate: 22 entries, 699 tests, no downgrades.
@jcgraciosa
jcgraciosa requested a review from lmoresi as a code owner September 29, 2026 07:36
@github-actions

Copy link
Copy Markdown

🔍 Architectural Review Checklist

Thank you for submitting an architectural review! Reviewers should validate:

Design & Architecture

  • Design rationale is clear and well-justified
  • Trade-offs are documented with alternatives considered
  • System architecture is comprehensible
  • Integration points are clearly identified

Implementation

  • Implementation matches documented design
  • Code quality meets project standards
  • Breaking changes are identified and justified
  • Backward compatibility is properly addressed

Testing & Validation

  • Testing strategy is adequate for the changes
  • Test coverage is sufficient
  • Edge cases are properly covered
  • Performance impact has been assessed

Documentation

  • Known limitations are clearly documented
  • Benefits are quantified with metrics
  • User-facing changes are documented
  • Migration guide provided (if needed)

Review Process: See CODE-REVIEW-PROCESS.md

Approval: This PR merge = Review formally approved

@github-actions

Copy link
Copy Markdown

✅ Test Suite: success

Title and summary only, the key is
unchanged, so the gate and its history are unaffected.
@github-actions

Copy link
Copy Markdown

✅ Test Suite: success

ci(container): build HPC images for Gadi, Kaiju and Setonix from shared recipes
@github-actions

Copy link
Copy Markdown

✅ Test Suite: success

@lmoresi lmoresi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Has been reviewed by the team and by the steering committee

@lmoresi
lmoresi merged commit 561e382 into main Oct 13, 2026
5 checks passed
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.

5 participants