Skip to content

Fault ribbons from the fault's own surface, and the fault-patch multigrid (#629) - #638

Merged
lmoresi merged 52 commits into
developmentfrom
feature/fault-outcrop-3d-cap
Aug 26, 2026
Merged

Fault ribbons from the fault's own surface, and the fault-patch multigrid (#629)#638
lmoresi merged 52 commits into
developmentfrom
feature/fault-outcrop-3d-cap

Conversation

@lmoresi

@lmoresi lmoresi commented Aug 24, 2026

Copy link
Copy Markdown
Member

Fault-resolving meshes built from the fault's own surface, the solver machinery that makes them cheap, and the measured record behind both. This is the #629 campaign branch; the issue thread carries every measurement in sequence.

What this delivers

Mesh side — the fault is the input. The user supplies their own discretisation of the fault (a structured grid in 3-D, polylines in 2-D; curved is fine) and one call builds the resolved band by offsetting that discretisation — no remeshing — embeds it, labels and splits the mid-surface, and returns levels that nest by construction:

  • place_fault_ribbon(base, sheet_grid, width) — 3-D: extruded prism band (always two layers across any width), split mid-surface as a boundary ready for add_fault_bc, nested 2:1 bridge level.
  • place_fault_ribbon_2d(base, traces, width) — 2-D networks: several strands placed sequentially, stop-short (the junction ruling: the intact gap is the linkage), cut and split in one add_fault network call.
  • place_thin_volume(..., mesher="ladder") — the underlying band builder. Straight 2-D traces keep the gmsh transfinite path; curved 2-D and all 3-D bands are pure numpy extrusions with mitred offsets and loud refusals (over-tight bends, inverted extrusions, protruding bands).
  • mesh.cells_labelled(name, value) — empty-safe cell masks from placement labels.

Three contracts, each learned from a measured failure: the supplied geometry is honoured exactly (the tip margin is extrapolated surround along end tangents — shrinking the request biased 3-D peak slip by 14%); any painted rheology stops at the fault footprint, never the margin (whole-band paint made the fault mechanically longer than requested); and every coarser level subsamples one parametrisation (independently recomputed normals or reach vectors break vertex nesting — measured in both dimensions).

Solver side — the fault-patch multigrid. The design note (docs/developer/design/fault-patch-multigrid-2026-08.md) is the specification; in brief: ordinary Galerkin multigrid handles everything smooth, and small strong ASM subdomains keyed on the fault physics restore the multigrid contract where coarse spaces cannot see.

Measured record (rig scale; the #629 thread has full context)

  • 2-D composed benchmark (enshrined as test_1022_composed_ribbon_fmg.py): FMG 8–9 iterations against GAMG 82 at native operator density; slip and leak identical; the zone patch takes a 1e4 contrast from 34 iterations to 2.
  • 3-D curved fault: FMG 6–8 against GAMG 56–70, physics identical; [L0, L1] + finest reaches GAMG wall-clock parity with 8x iteration headroom; the AL penalty at gamma = 1 puts FMG ahead outright.
  • Split vs TI on identical geometry: element budgets within 0.2%; the split representation is w-independent to 0.1%; TI converges onto it as w shrinks, with the bias largest on the strand with the smallest length/w.

Tests

New or extended: test_1022 (composed benchmark, tier B, negative control verified), test_0855 (3-D ladder geometry: exact volume, curvature refusal, nesting; the curved 2-D ladder; the two-strand 2-D wrapper), test_1017_nested_native_and_fac, test_08600862 (outcrop, daylighting, conforming sheet), parallel ptest_0854/0855. Touched suites and level_1 and tier_a pass locally after the development merge.

Known-remaining, deliberately out of scope

  • The UW_FAC_* / UW_CUSTOM_MG_* / UW_MG_SMOOTH_ITS environment knobs are still TODO(MEASURE) A/B affordances; retiring them awaits the production-proportion measurements.
  • The FAC patch split and the parallel split of fault networks are serial; the parallel design rules are recorded in the design note (seam consistency is the one item with valence).
  • visualisation/glyphs.py duplicates PR Visualisation: principal-stress glyphs and stress trajectories #601's file byte-for-byte so the fault figures work from this branch; whichever PR merges second dedupes cleanly.

Underworld development team with AI support from Claude Code

lmoresi added 30 commits August 16, 2026 21:44
…h walls

The zone outcrop no longer needs an axis-aligned wall. The cap over the
bowl is re-triangulated PER COPLANAR BOUNDARY REGION, each piece meshed
flat in its region's own plane, so the cap stays exactly on the faceted
surface and volume conservation survives. Where the band crosses a
crease the two sides segment the line differently (mesh vertices against
assembly nodes), so a 1-D overlay merges both node sets by arc
coordinate and keeps each elementary sub-segment for the side(s) whose
bowl covers it and the band does not; a one-side segment must be a whole
mesh edge, because a cavity-shell face matches it.

The frame rule (_outcrop_frame_3d) separates the two notions the third
dimension forces apart. Where the cavity may OPEN is by SMOOTH WALL —
coplanar regions grouped across low-dihedral creases — so a faceted
sphere's bowl legitimately spills onto facets beside the band's own
while a box's other walls stay refused. What the carve may DELETE is by
shape: band-covered vertices, single-region interiors, and interior
vertices of straight creases between touched regions; a vertex where
three or more regions meet is the domain's shape and is protected. A
protected vertex whose whole cell star drops is not stranded: it is a
collar node, and the gap fill's tets reference it — that is how a
curved boundary keeps its faceting through the surgery.

Wall labels are restored per removed face (the 2-D per-segment rule one
level up): each new wall triangle takes the labels of the removed face
it lies on, so a bowl spanning several walls restores each wall's own.
The Euler gate now demands CONSERVATION of the input's Euler number
rather than 1 — a spherical shell is S^2 x I, Euler 2, and refused
before for its topology, not for any defect. The same assumption in
place_sheet and remove_embedded is marked TODO(BUG).

_trace_wall_code and its refusals are deleted; the box path runs
through the general machinery and the box oracle tests are unchanged.
Driven on a rotated box (no wall axis-aligned), a band across the
Top/Front box edge (trace on both walls), and a spherical shell outer
surface (75 trace facets, 6 vertices removed): volume conserved to
1e-12 in each.

Underworld development team with AI support from Claude Code
…n a sphere

Three configurations, each the failure mode of a different assumption
the box-framed cap made: a rotated box (no wall axis-aligned; the
single-region collar), a band across the Top/Front box edge (the crease
overlay conforms the two sides' differing segmentations, and each
wall's labels are restored on its own side), and a spherical shell
outer surface (every facet its own region, every vertex protected
faceting — the case the trace design exists for). Each runs its
negative control first — the identical census on an interior twin
counts no trace — and each ends in the P2 Poisson oracle, exact for a
quadratic through the zone. Volume conserved to 1e-12 throughout; the
shell's Euler number 2 comes through the conservation gate.

The parallel file gains the rotated-box outcrop at np>=2 with the same
mesh and patch as the serial test: the info dict is identical on every
rank and the trace count matches the serial value (28 facets at
np=1, 2 and 4), so the general path is partition-independent. No
boundary face is left without a wall label at any rank count.

The spherical control sits mid-gap of a 0.75-thick shell: a one-cell
chain from a victim corner spans h, so an interior zone needs
~(clearance + 1) * h to spare on both sides — thinner shells refuse
interior zones at this resolution by the carve's own clearance gate.

Underworld development team with AI support from Claude Code
The collar-vs-bowl consistency check raised on the surgery rank inside
the relabel block, which is outside any try — a hang at np>=2. It now
sets the block's failure and flows through the allgather like every
other refusal there.

Underworld development team with AI support from Claude Code
The same gate place_thin_volume already fixed: demanding global Euler
number 1 encodes a ball-topology domain, and a spherical shell —
S^2 x I, Euler 2 — was refused for its topology rather than for any
defect of the surgery. Both gates now compare against the input mesh's
own number. Pinned by a regression test: a sheet embeds mid-gap in a
spherical shell and its removal clears the label again, both passing
their volume and conservation gates.

Underworld development team with AI support from Claude Code
The gate catches unmeshed solids — an O(1) relative defect — but its
reference, OCC's getMass on the clipped boolean, is only accurate to
~5e-7 relative when the domain tool carries many faces (measured on a
16.6k-facet adapted spherical boundary: the honest mesh volume exceeds
the reported CAD mass before any snap runs). At 1e-9 the gate refused
correct assemblies; it now allows 1e-6, far above the kernel noise and
far below any real missing-solid defect.

Underworld development team with AI support from Claude Code
OCC's boolean leaves clipped nodes up to ~4e-7 off the tool's own
planes on O(1) geometry against a many-faceted tool; the 1e-9 snap
missed them, so a crease-crossing node of the band outline was not
recognised as on-crease and the collar meshed a 2-metre sliver beside
the crease (measured on a 1000 km megathrust against an adapted
spherical boundary; the 2-D fill then refused with moved nodes). The
snap tolerance now sits above that noise and below any layer mesh
size, and candidates are masked by distance to the boundary FACETS
first — the planes are infinite, and at this tolerance every point in
space is near some plane of a many-faceted tool.

Underworld development team with AI support from Claude Code
Two mechanisms a 1000 km outcrop band forced, plus the diagnostic that
found them. _collapse_boundary_imprints_3d is the 2-D imprint collapse
one dimension up: where the band outline grazes a boundary vertex
(measured: a 2.4 m gap the collar meshed as a sliver and the fill
refused as moved nodes), the outline is rerouted THROUGH the vertex —
the nearest outline node moves onto it, or the outline edge splits at
it with every incident tetrahedron bisected. A move or split must keep
every incident cell's volume healthy, else the vertex is skipped and
keeps its sliver (the status quo, not a defect); the band still tiles
the same faceted surface, so the domain's shape is untouched.

An outline edge lying ALONG a crease is no longer refused when the
cavity covers both sides: the band reaches the crease there, so the
edge bounds the collar piece on the FAR side of the crease, not the
owning band triangle's own region. The refusal remains for a band
bounded by a crease with no bowl beyond it.

A collar piece that fails to mesh now names its pinch — the thinnest
node-to-segment gap and the node kinds — which is what separated this
sliver class from the OCC placement noise the snap fix covers.

Known limit, measured and deliberately not papered over: refining a
SPHERICAL boundary projects new facet vertices onto the true sphere,
and the creases between the resulting nearly-coplanar sub-facets are
laterally fuzzy at (placement noise)/sin(theta) — wider than the
band's own feature spacing, which defeats the crease overlay. A
widened, fuzz-aware overlay tolerance was tried and withdrawn: it
traded the sliver for misclassified chains on the honest-crease tests.
Outcrops on adapt-refined spherical boundaries stay refused by the
fill's own gates until the crease representation is rethought.

Underworld development team with AI support from Claude Code
The sheet path joins the general boundary machinery: one clip, one
frame, one collar (maintainer ruling 2026-08-18 — multiple placement
paths are themselves the defect). The Sutherland-Hodgman box clip and
the wall-code frame are deleted.

The discrete clip primitive cuts the authored triangulation against the
gathered boundary complex directly: per-component outward orientation
(a shell's inner surface signs opposite to its outer), signed-distance
classification, sequential cuts by the COPLANAR REGIONS' planes — a box
wall cuts as one plane however it is faceted, or the two triangles
sharing a cut edge key their cuts by different facets and the sheet
tears (measured: a duplicated node on the box top wall). Side cuts are
re-derived from the original sheet edge's endpoints and interned by
(edge, region), so neighbours whose sides are truncated differently
produce the bitwise-identical node (measured: ~1e-17 duplicate pairs on
the sphere without it). A crossing that can touch a locally concave
crease (an inner boundary) refuses loudly — the polyline cut is not
built — and every kept node is gated inside the domain on exit.

The carve takes the frame's two masks (open_deletable / open_near) like
the volume's carve, with the same protected-collar-node rescue; the
outcrop frame accepts a trace CHAIN of edges as the footprint alongside
a band of triangles; and the collar embeds the chain through its pieces
instead of cutting a hole — chain nodes at crease crossings land ON the
crease and enter the 1-D overlay as 'a' nodes, the runs between
crossings embed per region (_gmsh_fill_2d now takes several polylines),
free ends as gmsh's ordinary free-end embed. A trace edge along a
crease, or off the bowl, refuses with the reason. The overlay's local
loop variable is renamed crease_chain — it shadowed the new parameter,
which made the volume path read a stale trace.

The trace chain's edges carry <label>_trace in the result, the wall's
labels are restored per removed face (the volume path's rule), and the
counts are gated collectively. Box oracle unchanged (test_0854, 8/8;
area matches Sutherland-Hodgman bit-for-bit in the spike); the sphere
outcrop that could not run at all now embeds with volume conserved and
the P2 oracle exact (test_0860); trace counts identical at np=1/2/4
(ptest_0854).

Underworld development team with AI support from Claude Code
Claude-Session: https://claude.ai/code/session_01WDKP73LD3dCFQSNgEXbbw9
The hybrid-seam study measured the defect this guards against: a split
surface terminating against something that cannot slip ends as a free
crack tip, slip pins to zero, and the composite is worse than either
pure representation (74.6% vs 100/105%). The outcrop is that join one
level up, so the acceptance test is kinematic — measured slip > 0 at
the trace against a pinned control — not mesh gates alone.

The placement half asserts today: every trace edge on the wall AND
bounding a labelled fault face, the wall's labels restored beside it.
The kinematic half attempts split_along_label_3d through the wall and
SKIPS at its daylighting refusal — the body below the gate is the
acceptance criterion, ready to run when feature/fault-split-node learns
to duplicate the trace chain.

Underworld development team with AI support from Claude Code
Claude-Session: https://claude.ai/code/session_01WDKP73LD3dCFQSNgEXbbw9
The boundary snap's tolerance was raised to 1e-6 to cover OCC's ~4e-7
boolean placement noise (34ea192), but the test's untouched-control
point stayed at 2e-9 — inside the new tolerance, so it snapped and the
test failed. The control moves to 2e-5: above the tolerance, below any
real feature scale. A branch push does not trigger CI, which is how
this shipped red.

Underworld development team with AI support from Claude Code
Claude-Session: https://claude.ai/code/session_01WDKP73LD3dCFQSNgEXbbw9
Ruling (2026-08-18): split-node faults do not split through the surface.
They stop an element or two below, blind, with a damaged region above
carrying the deformation to the surface — the junction lessons applied:
a contact tip inside weak material is free, a tip at the weak zone's
edge is pinned, and an abutting composite is worse than either pure
form. This supersedes the split-through-the-wall acceptance this file
first carried; the gated body that waited on a daylighting split is
removed.

The kinematic acceptance turns the listric handover rule vertical,
against a FREE surface, and is validated by running (probe in
~/+Simulations/blind_fault_surface_expression/): a blind frictionless
split fault two cells under the surface, solved bare / damage-abutting /
damage-enclosing, orders strictly on near-tip slip (0.45 / 0.65 / 0.80)
and on surface localization (37% / 44% / 50%). The abutting case is the
deliberate negative control — the seam defect the overlap margin exists
to avoid. The structural test keeps the placement contract: the trace
chain labelled through to the wall, now as the surface LOCATOR for the
damage region rather than a split path.

Underworld development team with AI support from Claude Code
Claude-Session: https://claude.ai/code/session_01WDKP73LD3dCFQSNgEXbbw9
…boundary

The ruling's other half: the job is still to figure out the
intersection so the fault can stop BEFORE it hits the surface — it just
never meshes or splits all the way there. place_sheet(..., setback=d)
clips the sheet against the boundary offset INWARD by d (each coplanar
region's plane shifted along its inward normal; on a curved boundary
the shifted faceted planes, within a sagitta of the true offset): the
placed sheet arrives BLIND, its rim strictly interior, so
split_along_label_3d takes it as it stands — the daylighting refusal
never fires. The would-be intersection with the true boundary is still
computed from the unclipped input and returned as
info['surface_trace'] — the locator for the damage region above.

The clip's output is made split-safe by construction: a cut corner
polygon (two side-rim originals plus cut nodes) admits no triangulation
without an all-rim face, which the split refuses however fine the sheet
(measured: 3 such faces at every density tried). The face's longest
interior edge is split at its midpoint — bisecting both sharing faces,
rim edges (the trace included) untouched, no child worse-shaped than
its parent. A centroid split was tried first and REJECTED: a centroid
inside a sliver corner face drove the gap fill to 1e-23 cell volumes
and a P2 error of 0.64 on the shell outcrop.

test_0861 gains the end-to-end workflow test: a through-running sheet
placed with setback=0.25 arrives with no trace on the wall, its
shallowest vertex exactly a setback below it (the box's offset plane is
exact), surface_trace on the true boundary, and the placed patch splits
with the Plus side carrying every placed face. Box oracle and the
outcrop suite unchanged (test_0854 8/8, test_0860 4/4, suite green,
np=2/4).

Underworld development team with AI support from Claude Code
Claude-Session: https://claude.ai/code/session_01WDKP73LD3dCFQSNgEXbbw9
…tor marches

Physics-first ruling (2026-08-19): choose the fault's blind depth from
the physics and adapt the resolution to meet it. Two library changes
make that workable on the meshes it needs — locally refined shells
whose boundaries snap to the true sphere.

The clip's concave refusal becomes a MEASURED gate. A snapped
multi-resolution boundary is genuinely non-convex where fine facets
meet coarse chords, but the concavity DEPTH there is the sagitta
mismatch — metres at Earth scale — while an inner boundary's is the
facet size. The sequential plane clip over-cuts by at most that depth,
so a crossing is allowed when the deepest concave crease among the
near facets stays under 20% of the setback, and refused otherwise;
setback zero (an outcrop, where cut nodes must land on the complex
exactly) still refuses any concavity at all, as before.

The surface-trace locator contours the boundary signed distance over
the sheet's own triangulation (marching triangles): every crossing
point interpolates on a sheet EDGE from that edge's two vertex
distances, so the two triangles sharing it produce the identical point
and the polyline chains exactly, with no tolerance welding, on ANY
boundary — concave, graded, snapped. A direct triangle-triangle
intersection was tried first and REJECTED by measurement: robust-
geometry endpoint mismatches fragmented a 300 km trace into 148
components. Locator accuracy is the linear interpolant's,
O(spacing^2/R) — metres, which is the locator contract.

Verified end to end at Earth scale (probe:
~/+Simulations/spherical_slab_outcrop/megathrust_blind_adapted.py): a
19,768-cell shell adapts to 288,525 cells (proportional grading
h <= 0.5*distance — required, or coarse deep-rim tets span to the wall
and the carve refuses; edge_split engine — NVB's volume proxy leaves
diameters ~3x coarser than the carve's reach rules read), and the
megathrust places blind at 15 km = 2.5 local elements with the trace
located. Suites unchanged: test_0854/0859/0860/0861 21/21, spike green.

Underworld development team with AI support from Claude Code
Claude-Session: https://claude.ai/code/session_01WDKP73LD3dCFQSNgEXbbw9
…ulates

The ruling (2026-08-19): the authored fault triangulation is DATA, at
whatever spacing the source provided — but the embedded fault's
resolution must match the mesh it cuts, or the verbatim embed forces
sliver cells around every mismatched sheet triangle. place_sheet gains
size= — the counterpart of place_thin_volume's size, closing the
asymmetry the unification left: the clipped sheet's rim is compressed
to its exact corners (a too-fine authored rim coarsens here), each
straight run is resampled at the target, and the interior is re-meshed
by gmsh in the sheet's own plane. Planar sheets only (a curved surface
needs a parametric remesh, refused with the reason), and not for an
outcropping sheet — its trace chain must stay on the boundary complex
verbatim, so blind (setback) and interior placements only.

The all-rim split-safety pass is factored out (_split_safe_triangulation)
and applied to the resampled triangulation too: a gmsh planar mesh of a
polygon produces corner faces with all three vertices on the rim just
as the clip's corner polygons did.

test_0861 gains the workflow test: a deliberately coarse 3x3 authored
sheet placed blind with size=0.1 refines to gmsh-quality faces (min
quality > 0.3 asserted), keeps the blind rim exactly on the offset
plane, and splits with the Plus side carrying every face. Suites
unchanged (0854/0859/0860/0861 22/22, spike green).

Underworld development team with AI support from Claude Code
Claude-Session: https://claude.ai/code/session_01WDKP73LD3dCFQSNgEXbbw9
A reach-aware spatial hash (_reach_query: points binned per octave of
their own threshold) and a culled distance (_sheet_distance_within:
exact wherever it is below the per-point reach, sentinel above it)
replace the whole-domain per-triangle sweeps at the placement's nine
hot sites — the gather marks, the carve's victim distances, its
straddle sweep and centroid rule, in the sheet and volume paths alike.
Per-point reaches matter: the thresholds scale with the local cell
size, so a single cutoff cannot cull a graded mesh. Every caller
thresholds where it reads the distance, so decisions are identical —
verified: the placed mesh is bit-identical on the 452k-cell
middle-ground benchmark, and place_sheet drops from 555 s to 67 s
(the 446 s _sheet_distance share to under a second; the sewn rebuild
is now the placement's largest piece at 30 s).

Underworld development team with AI support from Claude Code
Claude-Session: https://claude.ai/code/session_01WDKP73LD3dCFQSNgEXbbw9
Three repeated whole-mesh walks removed from the adapt loop, none
changing a single cell of the output (bit-identical child on the
452k-cell benchmark):

- cell_diameters(return_tables=True) hands its cell-edge and
  edge-length tables to bisect_longest_edges, which was re-deriving
  both in the same pass — the per-cell closure walk is the loop's
  dominant Python cost and was paid twice per pass (309 sweeps -> 155).
- cell_diameters' per-cell max is vectorised (a simplex has a fixed
  edge count, so the ragged list stacks).
- The MG level selection re-measured every retained generation's
  5th-percentile diameter at the end — 76 more topology walks — when
  the marking pass had just computed exactly those numbers; they are
  cached per-dm and passed as resolution_hint.

Adapt on the benchmark: 658 s -> 551 s. The remaining ledger is the
engine surgery recorded on #610 — per-pass clone + label writes inside
the split call (242 s), MG parent/prolongation maps built per pass
(107 s), independent-edge selection (54 s) — the plan-in-numpy /
apply-back-to-back and multi-edge-template items.

Underworld development team with AI support from Claude Code
Claude-Session: https://claude.ai/code/session_01WDKP73LD3dCFQSNgEXbbw9
The subsampler was already discarding every per-pass parent map whose
span covered more than one pass — nearly all of them — so ~80 s of the
benchmark's 107 s of map building produced maps that were thrown away,
while the retained multi-pass levels got None and the any-degree
transfer fell back to the geometric builder. The engine loop now
records nothing; the subsampler derives parents for the 3-4 RETAINED
pairs from the composed vertex transfer (nested_cell_parents is
topological through the transfer, and a descendant's referenced coarse
vertices are corners of its ancestor at any depth), so the deferral is
also an upgrade: multi-pass levels now carry exact parents.

Measured on the 452k benchmark, bit-identical child: parent-map cost
86 s (x75) -> 7.8 s (x9); adapt 551 -> 473 s. The bisect interior was
also split with instrumentation: of its 242 s, the native transform's
setUp+apply is 187 s (~2.5 s per application — the per-pass C cost is
honest, the x76 pass count is the waste) and the independence pruning
54 s; clone and label writes are negligible. The remaining #610 levers
are therefore pass-count reduction (multi-edge templates, C-side) and
the plan-in-numpy replacement of the per-pass Python (~170 s).

Underworld development team with AI support from Claude Code
Claude-Session: https://claude.ai/code/session_01WDKP73LD3dCFQSNgEXbbw9
The metric's far clip must sit above the base mesh's measured cell
DIAMETERS, not its nominal gmsh cellSize: diameters run 1.2-2.5x the
target edge length, edge_split marks on diameter, and a clip below them
refines the entire domain once — wasted cells AND a measured
de-conditioning of the far field (median shape quality 0.372 -> 0.295),
which every later adapt and solve inherits. Found by eye in the viewer
(scattered one-level patches across the globe), confirmed by
measurement, fixed by h_far >= 1.05 * cell_diameters(base.dm).max().

Underworld development team with AI support from Claude Code
Claude-Session: https://claude.ai/code/session_01WDKP73LD3dCFQSNgEXbbw9
…620)

The zone walk went through _cells_on_edge, an EDGE walk: correct for a
2-D facet (an edge), one level too low for a 3-D facet (a face), where
support-of-support lands on nothing and the zone comes back all-False,
silently. The method had only ever seen 2-D meshes. Facets now take
getSupport directly — the cells, in any dimension — and the edge walk
is kept for labelled edges (a trace chain).

Fixes #620.

Underworld development team with AI support from Claude Code
The Mesh-level form of place_sheet, mirroring add_conforming_surface:
the sheet is cut at the finest level ONLY, and the child inherits this
mesh plus everything below it as its coarse multigrid tail — the coarse
levels carry neither the cut nor the label, and do not need to (the
Galerkin coarse operators are formed from the fine operator; measured
in add_conforming_surface's note). The adoption — wrap, bookkeeping,
and the "a cut earns a level only if it is genuinely finer" subsample —
is extracted from add_conforming_surface into _adopt_cut_child and
shared, so the two dimensions cannot drift.

The method takes explicit (points, triangles, name): a sheet is DATA —
a slab model, an authored parameter-space triangulation — whose
connectivity must be embedded verbatim, and FaultSurface re-derives its
triangulation so it cannot carry an authored one. setback (blind
faults, surface_trace in child._surface_info), size (resample to match
the mesh being cut) and clearance pass through to place_sheet.

test_0862 mirrors the 2-D contract of test_0844: the tail composition
and the replace-not-stack decision, the named boundary and its zone,
the untouched base, chaining, the duplicate-name refusal, and a solve
with a per-cell contrast across the sheet consuming the cut mesh as it
stands.

Underworld development team with AI support from Claude Code
_build_3d(mesher="place") placed each sheet at DM level and wrapped the
result in a bare Mesh, so the adapt child's multigrid tail died at
placement and every solve on the composed mesh fell back to algebraic
multigrid — not by design, but because the handoff that
add_conforming_surface performs in 2-D was never written here. The
route now chains add_conforming_sheet, so each cut child inherits the
hierarchy; the split at the end still forfeits it (see add_fault), but
the intermediate cut mesh now carries the tail that split-aware
multigrid would need.

The recorded place-route pathology (solve 3850 s vs 125 s embed, on
edge_split children) predates this handoff and wants re-measuring on
top of it.

Underworld development team with AI support from Claude Code
…rator health (#621)

The 3850-vs-125 note said the composed chain's solve was pathological on
adapt children. Measured again with the tail handoff in place: 27x the
cells embed builds for the same nominal sizes, 41x the time — which is
proportionate under 3-D Stokes scaling, with comparable per-cell cost,
one nonlinear iteration, machine-zero leak and agreeing slip. The
over-build is the route's own sizing (base at cellSize=h_far with
refinement=1 puts the far field at h_far/2). Docstring restated; the
sizing fix is #621. Companion measurements: #622 (custom-P V-cycles
across a cut level cost 7x more than GAMG saves), #623 (fill volume
drift on steep gradings).

Underworld development team with AI support from Claude Code
inject_custom_mg re-ran build + install on every solve: a duplicate
Jacobian assembly (done only to make the fieldsplit reachable), a PCMG
reset + Galerkin setup, and a from-scratch geometric transfer build —
measured at 55 s of every repeat Stokes solve on an 85k-cell cut child,
while the transfers depend only on the meshes and PETSc re-Galerkins
changed operator values at PCSetUp by itself. The guard checks the LIVE
PC (the managed block exists, is a PCMG, carries the hierarchy's level
count) against a marker written at install; any doubt re-installs — the
cost of a wrong False is one redundant install, the cost of a wrong
True would be a solve on a stale PC.

Measured (place_route_health benchmark): repeat-solve overhead 55 s -> 0;
a genuine solve (operator values changed) keeps mg:4its on the velocity
block against gamg:46 and now comes in ahead on wall time as well
(284.6 s vs 307.7 s with the fgmres outer).

Underworld development team with AI support from Claude Code
…e sub-blocks but never the top (#624)

SNES_Stokes_SaddlePt sets both fieldsplit sub-solves to fgmres but
never pushed an outer ksp_type, so every saddle-point solve ran PETSc's
default plain gmres around variable-iteration Krylov inner solves — a
non-constant preconditioner under which standard GMRES's recurrence
does not hold, the same reasoning as the velocity-block FGMRES note
(#147) one level up. Measured on a genuine 85k-cell contrast solve:
14,400 inner iterations / 543.9 s under gmres; 307.7 s under fgmres
(gamg velocity), 284.6 s (guarded custom-P). Pushed as a managed
option in both the constructor and the strategy setter, so an explicit
user ksp_type still wins.

test_0203's lower-bound test asserted a STRICT undercount on the first
solve's velocity count — an artifact of left-preconditioned gmres,
which burned one full preconditioner application before its first
monitor fired. Right-preconditioned fgmres forms the unpreconditioned
residual first, so the bound is now tight; the test asserts the
contract (second >= first, plus the honesty flag) instead of the
artifact.

The remaining genuine-solve bottleneck is the pressure sub-solve at its
200-iteration gasm cap, silently unconverged — #625.

Underworld development team with AI support from Claude Code
…), plus a zero-column repair for placed levels

The "rbf" transfer builder assembled and solved the GLOBAL dense
coarse-cloud kernel matrix and returned nnz/row == n_coarse — dense
Galerkin coarse operators, no conditioning path, a rescue that could
not scale (#429, agreed long ago and never landed; the interpolation
note recorded it as "Not rewritten"). It now delegates to the standard
kd-tree local linear-exact interpolator (kdtree.interpolation_matrix,
order=1) — the same polyharmonic kernel and reproduction guarantees,
sparse support, the interpolator the rest of the code uses.

A row-wise kNN builder guarantees nonzeros per ROW, never per COLUMN
(#424): on NON-NESTED level pairs — two independently placed meshes
(#626), a relaxed child — a coarse DOF outside every fine stencil gives
an empty column and a singular PtAP. The serial build loop now repairs
such columns by nearest-fine-DOF injection (weight 1, same component),
warns with the count, and still refuses if any column stays empty.
These are preconditioner transfers, not the discretisation: an injected
row costs iterations at worst, never correctness. First measured use:
the split-finest-over-unsplit-band ribbon hierarchy (6 of ~50k columns
repaired; converged, machine-zero leak, answer unchanged).

Suites: test_1015 / test_1016 / test_0753 — 28 passed, 1 pre-existing
conditional skip.

Underworld development team with AI support from Claude Code
…622)

The rotated custom-P path hardcoded coarse="svd" for the velocity
PCMG because a free-slip enclosure's Galerkin-coarsened velocity block
inherits the rigid-rotation null modes and redundant/LU zero-pivots on
them. But nsp being non-None cannot discriminate — it also carries the
constant-PRESSURE mode (enclosed domains), which lives outside the
velocity block — so a Dirichlet-walled problem with no rotation modes
still paid a DENSE SVD factorisation of a 3-D P2 coarse level.
_rotated_nullspace now records the count of VERIFIED rotation modes on
the solver, and the coarse solve is SVD only when that count is
nonzero; the fallback (count unknown) keeps SVD whenever nsp exists,
so free-slip shells are untouched.

Measured on the split-fault contact benchmark: the SVD was NOT the
dominant cost (the per-cycle weight is the level-smoothing footprint
of a localized-refinement hierarchy — the background rides at full
size on every band level — recorded on #622); the gate is correctness
of configuration, not the headline saving.

Suites: 0844 / 0845 / 1015 / 1016 / 0203 — 63 passed.

Underworld development team with AI support from Claude Code
The multigrid transfer needs more than DOF coordinates. To evaluate a coarse
Lagrange basis inside a parent cell it must know WHICH rows of
_get_coords_for_basis are that cell's own nodes -- and the section that answers
that lives on a coordinate DM _get_coords_for_basis builds and then destroys.

_basis_coordinate_dm is extracted from _get_coords_for_basis (second occurrence
of the same construction) so both read the same layout, and _cell_node_indices
walks that DM's local section over each cell's transitive closure. Degree 3,
where an edge carries two DOFs, falls out of the offset/ndof expansion; a
discontinuous space hangs every DOF off its own cell and needs no special case.
Non-simplex meshes are refused explicitly: a tensor-product Q_k cell carries
(k+1)^dim nodes, so the total-degree monomial basis the transfer builds would
not be square against them.

Cached beside _coord_array and cleared with it -- both describe the same node
layout, and it is the DS re-creation, not node motion, that invalidates either.

Tested geometrically rather than against itself: every node a cell claims must
lie inside that cell, checked with barycentric coordinates built from the cell's
vertices, at P1/P2/P3 in 2D and 3D for both continuities. A wrong section offset
points at another cell's node, which is somewhere else in the mesh, so it cannot
pass. Also asserted: node counts equal C(k+d, d), continuous rows are all
covered, discontinuous rows partition cell-block-contiguously (the layout
_build_kd_tree_index_DS already assumes), and Q_k is refused.

Underworld development team with AI support from Claude Code
…geometric transfers, and FAC patch smoothing (#629)

Item 1 of the #629 program, plus the fill mechanism it exposed. A level
pair that is a native refine() pair (tagged by _coarse_level_meshes via
hierarchy-slot sentinels) now gets the EXACT nested prolongation at any
polynomial degree: parent cells recovered topologically from the two DMs,
weights by the dual-basis identity W = B M^-1 under the parent's affine
pullback (the #425 construction; verified exact to 1e-15 at P1/P2 in 2D
and 3D). PETSc's DMCreateInterpolation general path was measured NOT to
be the embedding (row sums to 1.375, quadratic reproduction error 1e-2)
and is not used.

The larger density win turned out to sit beside it: the point-located
builders emit ~1e-16 junk weights that are STRUCTURAL nonzeros, and
Galerkin RAP fills by structure, compounding level over level. Dropping
them (_drop_structural_zeros) collapsed the split-contact benchmark's
Galerkin chain from 319/481/265/90 nnz/row to 138/119/173/90 — the item-1
acceptance band — and the warm full-tail contact solve from 97 s to 52 s
at bit-identical answers (slip 0.1509, leak 0). Full-tail iterations
reproduce the recorded 6-7 under the robust smoother (the 6-its dial row
was gmres/4; the script's fast row runs 12).

Item 2: FAC/MLAT patch-restricted smoothing. Each level's patch is read
off its own transfer (identity row + bit-coincident node = background,
per NODE so the rotated path's per-node Q cannot disturb it; slit
duplicates count as patch; halo = one coarse-cell layer through the
transfer graph), stashed on the hierarchy, and consumed by
_configure_pcmg: the level smoother PC becomes ASM with that single
subdomain. Three configuration findings are load-bearing:

* BASIC, not restricted, ASM: discarding the halo correction stalls the
  outer KSP at 80-375 iterations where basic runs 6 against a
  whole-level baseline of 4 (banded Poisson, 4-level tail).
* The subdomain solve is SOR, the patch twin of the whole-level
  smoother: PCASM's default ILU-0 takes a NUMERIC_ZEROPIVOT on the
  rotated Galerkin patch block (min |diag| 8e-5 near the constraint;
  PC_FAILED -11 before the first iteration).
* The rotated path must force the FAC levels' smoother setup before its
  options-DB cleanup: PCASM creates the sub-KSP lazily at first apply,
  after the keys are gone, so sub options silently never applied.

A uniform pair (patch = everything) declines to whole-level smoothing.
Measured on the split-contact ribbon: correct answers with rotation and
contact, but a net cost at THIS toy's proportions — the ribbon band is
66-82% of every refined level's DOFs, so there is no background to save
and the ASM gather/scatter is overhead (warm 60 vs 52 s). The FAC win
requires production proportions (thin patch in a large domain), which is
where the item-3 contrast test goes next. The UW_FAC_* / UW_CUSTOM_MG_*
environment knobs are TODO(MEASURE)-marked A/B affordances for that
campaign.

Tests: test_1017_nested_native_and_fac.py (exactness by degree-k
reproduction, tag discipline, split classification with a geometric
oracle, end-to-end ASM solve vs GAMG); test_1015/1016/0753/0846/1014/
1017/1018 suites green.

Underworld development team with AI support from Claude Code
…tem 3 diagnosis)

The contrast sweep's FMG degradation (velocity 7 -> 10 -> 36 its over
1e0 -> 1e4, where constant V-cycles are the expectation) is DIAGNOSED
and FIXED at the smoother:

* Level ablation at 1e4: full/no_mid/two tails converge in 20/19/24
  its — the coarse corrections contribute nothing under zonal contrast;
  the weak-band modes are invisible to every coarse space, including
  the band-carrying mid level.
* Doubling the smoother (gmres/8 vs /4) exactly halves the count
  (20 -> 10): convergence is proportional to total fine-level smoothing
  work — the signature of smoother-limited band modes.
* FAC with a DIRECT subdomain solve (sub_pc lu) on the patch — which
  contains the weak zone — collapses the count to 2 its at 1e4 and
  3 at 1e6: V-cycle constancy across six decades of contrast, below
  the flat-viscosity count (7).

Two supporting changes: factorization sub-solvers get
sub_pc_factor_shift_type=nonzero (the rotated Galerkin patch block
carries near-zero pivots from the constraint-zeroed transfer rows —
unshifted LU takes NUMERIC_ZEROPIVOT even as an exact factorization),
and UW_MG_SMOOTH_ITS joins the TODO(MEASURE) campaign knobs.

Remaining engineering to make the strong-patch cycle cheap: shrink the
subdomain from the whole refined patch to the weak zone + halo, and
reuse factors across cycles; the wall-time gate at contrast stays the
pressure gasm cap (#625).

Underworld development team with AI support from Claude Code
…duplicated nodes, not the refinement patch (#629)

The refinement patch conflates the smooth refined bulk (well served by
ordinary multigrid) with the fault zone (the only set needing the strong
solve). The slit mode keys the subdomain on the split-duplicated nodes
with operator-sparsity overlap as the halo — proportional to the fault
trace, not the refinement. Validated on the 2-D line rig: constant 6-7
velocity iterations across six decades of viscosity contrast on a 5%%
subdomain, against a GAMG control that degrades to its iteration cap.

Underworld development team with AI support from Claude Code
lmoresi added 15 commits August 22, 2026 19:52
The placed ribbon joins the multigrid design in three roles: the bridge
level structure between the standard mesh and the patch, the AUTHORED
damage-zone identifier (the placement zone label survives the split as
cell children — never a distance-mask staircase or a cells_supporting
zipper), and one mesh discipline for split, TI, and weak fault
representations alike. Junction default: split segments a short
distance apart, the intact gap is the linkage; painted cores are
opt-in physics. Open engineering: partition weighting for the band and
split-pair co-residency in one mechanism.

Underworld development team with AI support from Claude Code
…rrection, #629)

Louis's correction: split surgery runs in the local frame after
distribution, so a pair is born on its parent facet's rank and cannot
be separated — no partitioner constraint exists. The real rules: never
distribute a pre-split mesh (the slit is a zero-cost graph cut, so a
partitioner would preferentially separate the sides while the contact
coupling lives outside the graph — gate that pipeline), and keep the
star forest consistent where a fault crosses a rank seam along strike
(the known np>=3 line-cut item). Ribbon balance reduces to ordinary
cell-count weighting.

Underworld development team with AI support from Claude Code
The finite-width ribbon is a modelling object in its own right, with
three sanctioned physics: a damage zone (when a damage evolution
equation is solved — damage is a field, not a paint), a nonlinear
plastic/yielding material (so failure patterns EMERGE in the resolved
band — the mechanism by which junctions form rather than being
authored), and a permeable zone for fluid flow. A hand-painted static
weak viscosity is none of these — neither gouge nor a fault. The fault
itself is always the split surface in the mesh; ribbon physics
complements the slip surface. With none of the three physics present,
the ribbon carries background rheology and is purely resolution. All
roles compose with the solver design unchanged.

Underworld development team with AI support from Claude Code
…ication, #629)

A fault may legitimately be REPRESENTED as a weak / TI weak zone
carried by the ribbon — then w is a PHYSICAL parameter (the fault-zone
width), chosen appropriately and resolved by ~2 elements across. In a
split-node model, w is a mesh-bridging convenience with no physical
reading, and nothing rheological may be keyed to it. The recurring
campaign error was mixing the readings: split nodes plus band-wide
weakness with w chosen as a mesh number is neither model.

Underworld development team with AI support from Claude Code
… in-repo, composed benchmark test

The fault-zone patch key settles into API: set_custom_fmg(...,
fac_zone=mask | [masks]), validated against the finest mesh at
registration; the solver._fac_zone_cells attribute spelling is retired
loudly (setting it raises rather than declining silently — the #629
campaign's sharpest instrumentation lesson).

The #589 empty-stratum getIndices() segfault is fixed at source:
utilities/dm_labels.py provides label_stratum_indices() gated on
getStratumSize (safe on both the null-IS wrapper and values outside the
live set), and the dead `is None` guards in nvb/reconnect/fault_split —
petsc4py returns a non-None NULL-handle wrapper, so they never fired —
are routed through it. mesh.cells_labelled(name, value) is the
empty-safe cell-mask accessor for placement labels (the natural
fac_zone builder).

The transfinite ladder band (#595: three nodes across, rails + exact
centreline, mandatory for spine cuts) moves in-repo from the campaign
scripts as place_thin_volume(..., mesher="ladder").

tests/test_1022_composed_ribbon_fmg.py (tier B) enshrines the composed
2-D benchmark of record: native level densities, the 2-block finest
patch (zone + automatic structural union), iteration bounds, slip/leak
invariants. Negative control verified (FAC disabled fails the block
assertion). The benchmark reproduces bit-for-bit on the in-repo path;
the 3-D pure-contact composition measures 7 velocity iterations vs
GAMG 56 with identical physics (design note updated).

Underworld development team with AI support from Claude Code
…629)

A split-node fault is the efficient fault representation and runs no
zone patch: the structural patch is automatic where it matters, and
under pure contact the strong patch is redundant outright — measured in
3-D, velocity iterations are 7 with the zone patch, 7 with the
structural patch, and 7 with no finest patch at all (the cover gate
declines the band-shaped patch of the non-nested placed pair). The
fac_zone key is reserved for volumetric representations — weak / TI /
damage / gouge rheology, where the zone width is physics — and the
ribbon is never the key: it is resolution, not rheology.

The design note records the ruling under patch keying, plus the answer
to why 2-D and 3-D economics differ: the 2-D ladder's band levels nest
by construction (36 -> 72 rungs, an exact 2:1 transfinite pair), so
placed pairs behave like native refinement; the 3-D ribbon layers are
independent unstructured fills at unrelated sizes and share nothing,
which is what fattens the Galerkin chain and prices the V-cycle. The
3-D fix, when wanted, is nesting the band levels, not more patch.

test_1022 restructured to the ruling: test 1 enshrines pure contact
with the structural patch alone; test 2 covers the fac_zone union
machinery and the loud retirement of the _fac_zone_cells spelling.

Underworld development team with AI support from Claude Code
…esh (#629)

place_thin_volume(mesher="ladder") in 3-D takes the fault surface's own
structured discretisation — a (grid, normals) pair — and offsets it
±width/2 into two prism layers split to tets (Dompierre subdivision;
quad-diagonal compatibility proven against the analytic skin count).
The mid-surface is a real vertex sheet, so the slit is coordinate-set
selection with no plane test, and a 2:1 subsampled grid's band shares
every vertex with the fine band through placement and split. Exact
where exactness is checkable: planar volume to 1e-12; an over-curved
extrusion is a refusal, never a reorder.

Measured on a curved fault (sinusoidal bulge, campaign rig): physics
identical to GAMG, 6-7 velocity iterations against 60-65. The corrected
diagnosis, recorded in the design note: vertex nesting alone does not
collapse the Galerkin chain — the unstructured FILL SHELL between band
skin and coarse background is the node majority (51% of finest P2
nodes, nesting 3%), and vertex nesting is not P2 nesting (10% inside
the band). Thinning the shell (clearance floor) brings level 1 to
native density. The residual warm-time gap is the rotated path's
per-solve transfer rebuild (the un-guarded rotated twin of #622) — the
prerequisite fix for wall-clock claims on the contact chain.

Geometry tests appended to test_0855 (volume, curvature refusal,
nesting, input contract).

Underworld development team with AI support from Claude Code
The user supplies the fault surface as their own structured point grid
— curved is fine — and one call thickens that grid into the resolved
band (extruded prisms, no remesh), embeds it, labels the mid-surface
(coordinate selection + rim erosion, now in-repo as
_label_mid_surface), splits it into a frictionless-ready fault whose
label is a boundary for add_fault_bc, and builds the nested 2:1 unsplit
bridge level for the multigrid hierarchy. One parametrisation
throughout: normals default to grid-derived and the mid level always
subsamples the same field, so the levels nest by construction and the
inconsistent-normals footgun cannot be expressed. Defaults follow the
measurements (clearance 0.3, the thin fill shell); split=False keeps
the labelled unsplit mesh for painted weak/TI models on the same
geometry. The 3-D ladder assembly also accepts a bare grid now.

The campaign rig, rebuilt on this path, reproduces its record
bit-for-bit (21,352 cells, 1,150 slit faces, 675/675 nesting, level
densities 103/84/151/93, velocity 6 iterations, slip 0.1523).
End-to-end wrapper test added to test_0855.

Underworld development team with AI support from Claude Code
… lever is level economics (#629)

The design note's claim that the rotated/contact path rebuilds its
transfers every solve is retracted: it described the pre-fix state
from earlier in the campaign and was repeated without re-measuring. A
cProfile of the repeat solve shows the cache working end to end — the
geometry tier of _rotated_linear_cache carries the rotation and the
custom prolongations, the context carries the KSP/PC, and the operator
tier skips assembly and PCSetUp on an unchanged matrix; 46 of 48
seconds are inside the composite Krylov solve, with no build anywhere.

The warm cost structure is the Schur loop times the V-cycle price
(full factorization = one velocity solve per pressure iteration), and
the measured lever is tail depth: on the curved-ladder stack, dropping
the near-fine mid level reaches GAMG parity warm (24.0 vs 23.0 s) at 7
velocity iterations against GAMG's 65, with the remaining transfer
chain entirely native density — the Galerkin fat was the mid level
alone. The mid level's role is production proportions; at rig
proportions the tail of choice is [L0, L1] + finest. Physics identical
in every arm.

Underworld development team with AI support from Claude Code
…chanism)

Measured on the parity-tuned [L0, L1] + finest curved-ladder stack,
warm repeats, leak machine-zero in every arm: gamma=1 shortens the
pressure loop 22 -> 17 with the velocity count unmoved (7), taking the
warm solve to 19.1 s against GAMG's 23.0 — FMG ahead outright at flat
viscosity. gamma=10 over-stiffens the velocity block (7 -> 12) and
reverses the gain. No Schur plumbing was needed: the penalty is
viscosity-scaled by construction, so penalty/mu is uniform and the 1/K
pressure-mass preconditioner remains spectrally correct.

Caveats recorded with the result: under penalty the recovered p is the
Lagrange multiplier (p_mech = p - lambda*mu*div_u for any
pressure-dependent rheology), and derived quantities drift with gamma
(peak slip 1% at gamma=1, 3% at gamma=10 at rig resolution — the same
bias family as #633's vertex-sampled dynamic topography). gamma=1 is
the sanctioned choice.

Underworld development team with AI support from Claude Code
…rapolated, never confiscated (#629)

A fault specified from a structural model has its extent as data; the
wrapper previously took the grid as the band and labelled the slip
surface inset_rings inside it, so the requested fault came out smaller
than specified. Inverted: the grid IS the fault, labelled on precisely
the supplied points, and the band is built on the sheet continued
margin_rings rings outward along its own end tangents (_extend_grid —
linear per ring, corners consistent, normals continued the same way
and renormalised; no curvature the data never asserted). The 2:1
bridge level subsamples the extended parametrisation, so nesting is
unchanged. margin_rings >= 1 is enforced: a split may never reach the
band rim, and the margin comes from invented surround.

Measured on the curved rig (29x29 fault, margin 2): 1,566 of the 1,568
requested triangles split — only the two corner triangles erode (the
splitter's no-interior-vertex refusal, a half-cell nick) — nesting
867/867, chain 95/80/95 nnz/row, velocity 8 iterations. Peak slip
0.1737 against 0.1523 under the old inset: the confiscated rings were
biasing the physics by 14%, and the honoured value matches the
full-patch embed reference (~0.174). GAMG parity exact (slip 0.1737,
leak 2e-17, 70 its vs 8).

Underworld development team with AI support from Claude Code
… parametrisation (#629)

The 2-D ladder was transfinite and straight-only; the S-fault rig
(San Andreas bend + through-line branch, designed with Louis) needs
bent traces. _ladder_curved_assembly_2d is the 3-D extrusion one
dimension down: the polyline resampled equispaced in arclength, offset
±width/2 along mitred vertex normals (constant width through turns,
sharp turns refused), three rails of shared vertices, alternating
diagonals, mixed-orientation triangles a refusal. No gmsh, no CAD.
Straight polylines keep the gmsh transfinite path, so the recorded
composed benchmark stays bit-identical (verified).

The nesting contract carries over from 3-D and is enforced by API
shape: coarser levels must SUBSAMPLE one fine parametrisation — the
assembly accepts precomputed (samples, reach) for exactly that, and
independently recomputed reach vectors were measured to break rail
coincidence (only the spine nested). With the shared parametrisation:
132/132 mid band vertices coincide on the rig's tanh S trace.

Underworld development team with AI support from Claude Code
…#629)

The 2-D production path with the full contract set: each supplied
polyline IS a fault, its points becoming the band's spine vertices
verbatim (the curved ladder's (samples, reach) patches — one
parametrisation per trace, extended margin_rings by tangent
continuation); traces are placed sequentially as stop-short strands
(the junction ruling) and cut+split in ONE add_fault network call —
chained calls were measured to drop the earlier fault's pairing
records. The (samples, reach) tuple patch is wired through the 2-D
ladder hook with the domain gate shared between paths.

visualisation/glyphs.py brought over from feature/stress-glyphs
(PR #601) for the rig's standard figures: dCFF slip-vs-welded and
principal-stress trajectory nets over a fine grey mesh.

Validated on the S-fault rig (tanh S + through-line branch, the
geometry designed with Louis): 1,858 cells, native chain (24-27
nnz/row), sub-second warm solves, machine-zero leak on both strands,
first partitioning number branch/main = 0.41, locked welded state
reproducing uniform shear to 2.6e-5. Tests: curved-ladder nesting +
refusals, and the two-strand wrapper with honoured trace vertices.

Underworld development team with AI support from Claude Code
… — render nodally

Louis's catch on the S-fault rig's dCFF panel: resampling a recovered
P1 field onto a regular pixel grid via uw.function.evaluate produces
artefacts across the elements — grid points that straddle a facet get
located into a neighbouring cell with slightly-off reference
coordinates. The rule added to the skill: render derived fields
NODALLY on the mesh's own triangulation (vertex evaluation is exact
for P1 whichever cell the locator picks; VTK interpolates within
elements), and on a split mesh never Delaunay the DOF cloud — it
re-triangulates across the slit.

Underworld development team with AI support from Claude Code
…outcrop-3d-cap

# Conflicts:
#	src/underworld3/cython/petsc_generic_snes_solvers.pyx
#	src/underworld3/discretisation/discretisation_mesh.py
#	tests/test_0203_solver_wallclock_guard.py
Copilot AI lite review requested due to automatic review settings August 24, 2026 12:02

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@lmoresi

lmoresi commented Aug 24, 2026

Copy link
Copy Markdown
Member Author

Adversarial review (self-review per project practice; findings verified against the diff, most significant first).

1. The honoured-paint rule is documentation, not API. The campaign's measured lesson — rheology painted on the whole band makes the fault mechanically longer than requested (tip lobes ~2 elements past the mapped tips) — is enforced nowhere: both wrappers return band labels only, and every consumer (our own rig scripts included) reconstructs the fault footprint by hand with geometry-specific code. The mistake we made twice remains one careless mask away. Proposed fix, cheap and general: the wrappers already know each trace's extended parametrisation, so a nearest-sample test (is the centroid's nearest rung a user point or an extension point?) yields a per-strand footprint mask to return in info. We will push this.

2. place_fault_ribbon_2d accepts duplicate trace labels. Nothing rejects [("Main", A), ("Main", B)]; the failure surfaces downstream in the add_fault network call with an ambiguous-boundary shape rather than at the API boundary. One-line check. We will push this.

3. The ladder domain gate is bounding-box only. A structured band inside the domain's bbox but outside a non-convex domain passes _ladder_domain_gate_2d/the 3-D check and fails later in the carve with a less specific refusal. Loud either way, so correctness holds, but the error names the wrong cause. Documented limitation; a point-in-polygon gate is a follow-up, not this PR.

4. visualisation/glyphs.py duplicates PR #601 byte-for-byte (declared in the description). Reviewers should prefer merging #601 first; whichever lands second dedupes with no conflict, but two open PRs carrying the same file is a state to close quickly.

5. Scope notes verified rather than assumed: the UW_FAC_*/UW_CUSTOM_MG_* knobs remain TODO(MEASURE) as declared; the split path at np>1 goes through the gathered-serial topology (guards present at the four entry points), so the serial-only claim is about the FAC patch split and network seams, as the design note states.

Tests after the development merge: touched suites 66 passed; level_1 and tier_a 1065 passed / 31 skipped / 1 known xfail.

Underworld development team with AI support from Claude Code

… trace labels are refused

The fault-footprint mask is now returned by both wrappers instead of
living only in documentation: place_fault_ribbon reports
info["footprint"] and place_fault_ribbon_2d reports per-label
info["footprints"] — band cells whose nearest extended-parametrisation
sample is a USER point, so painted rheology and fac_zone keys can no
longer silently extend into the extrapolated tip margin (the mistake
measured twice in the campaign: whole-band paint put tip lobes about
two elements past the mapped tips). The test is geometry-free — the
parametrisation itself carries the user/extension distinction — so it
works for any curved strand.

place_fault_ribbon_2d also refuses duplicate trace labels at the API
boundary rather than failing downstream with an ambiguous-boundary
shape inside the add_fault network call.

Both fixes carry assertions in test_0855 (footprints strictly inside
the band, subset of it, per strand; the duplicate-label refusal).

Underworld development team with AI support from Claude Code
@lmoresi

lmoresi commented Aug 24, 2026

Copy link
Copy Markdown
Member Author

Review findings 1 and 2 are fixed on the branch: the fault-footprint masks are returned by both wrappers (finding 1 — the honoured-paint rule is now API, with tests asserting the footprint is a strict subset of the band per strand), and duplicate trace labels are refused at the API boundary (finding 2). Finding 3 (bbox-only domain gate) remains a documented limitation with a loud downstream refusal; findings 4 and 5 are state, not defects.

Underworld development team with AI support from Claude Code

…st-local-h headroom in test_0861

test_1021 asserted the pre-#629 blanket rule (the rotated coarse solve
is always svd). The campaign gated it: svd only when the rotated
problem carries a VERIFIED rigid-rotation null mode, because blanket
svd was measured as most of ~0.8 s per V-cycle (#622). The bundle
fixtures pin the inner annulus boundary, so no rotation mode survives
and redundant/LU is the correct coarse solve there — the assertions
now say so. The svd arm keeps its regression protection through a new
test: rotated free-slip on BOTH boundaries leaves the genuine rotation
mode, the builder must verify and record it, and the coarse solve must
be svd (this is exactly where redundant/LU hits its zero pivot, #306).

test_0861's two setback placements failed only on CI: with the 0.6
default clearance the carve cavity extends ~(clearance+1)*h_local, and
at the worst local h a different gmsh version deals this box that
overruns the 0.25 setback margin. clearance=0.3 (the measured
production thin-shell choice) keeps the cavity inside the margin at
worst-case h; the tests pass locally under both settings.

test_0054's failure is the CI-flaky hang-watchdog family (development's
own latest CI failed sibling test_0053); no change here — rerun.

Underworld development team with AI support from Claude Code
@lmoresi

lmoresi commented Aug 24, 2026

Copy link
Copy Markdown
Member Author

CI triage and fixes (f31b9eb), one cause at a time:

  • test_1021 (2 failures — genuine, reproduced locally): the test asserted the pre-Geometric aggregation for placed-hierarchy FMG: coarse levels as decimated point clouds, not band-only meshes #629 blanket rule that the rotated coarse solve is always svd. This branch gates it — svd only when a VERIFIED rigid-rotation null mode exists, since blanket svd was measured as most of ~0.8 s per V-cycle (Custom-P V-cycles across a cut level cost more than they save (7x slower than GAMG at 11x fewer iterations) #622) — and the bundle fixtures pin the inner annulus boundary, so no rotation mode survives and redundant/LU is correct there. The assertions now state the gated semantics, and the svd arm keeps fix(rotated-bc): Schur preconditioning parity + 3D rotation nullspace — Zhong #248 blow-out (44 its -> 1) #306's regression protection via a new test: free-slip on BOTH boundaries, where the rotation mode is genuine, the recorded mode count must be 1 and the coarse solve must be svd. All 19 tests in the file pass locally.
  • test_0861 (2 failures — CI-only, pass locally): the setback placements' wall margin held for our local gmsh but not CI's — the carve cavity extends ~(clearance+1)·h_local, and at the worst local h a different gmsh deals this box, the 0.6 default clearance overruns the 0.25 setback. The tests now use clearance=0.3 (the measured production thin-shell choice), which keeps the cavity inside the margin at worst-case h.
  • test_0054 (1 failure — pre-existing CI flake): the hang-watchdog family is flaky on CI independent of this branch — development's own latest completed CI failed the sibling test_0053, and nothing on this branch is reachable at the import path the watchdog arms (the new glyphs module is not imported by visualisation/__init__). No change; rerunning.

Underworld development team with AI support from Claude Code

…ver dumps sources (#638 CI, test_0054)

Root cause, established with native thread samples of two live hangs:
faulthandler.dump_traceback_later(repeat=True) walks live frames from
its C thread without synchronisation, and fired against a
still-importing interpreter that walk loops forever (locally: the C
thread pinned in dump_traceback for the whole sample window, the main
thread starved mid-import) or reads garbage and dies at SIGSEGV (the
CI -11). The env-armed watchdog was arming during `import
underworld3.mpi` — inside the very import it then dumped over. The
failure is conditional, which made it look flaky: it needs the import
slow (cold bytecode caches, as on CI or straight after a build) and
piped child output (as the test runs), and this branch's larger import
graph pushed the import past CI's 1.0 s trigger where development
stays under it.

Fixes, each a real hardening: (1) mpi.py preloads
traceback/linecache/tokenize so the reporter thread never enters the
import system; (2) _stack_dump formats with lookup_lines=False — file
names, line numbers and functions carry the hang report, and the
source-text reads were reporter-side IO against modules mid-import;
(3) report() re-arms its own Timer only — re-issuing
dump_traceback_later cancels the C thread and cond-waits on it
mid-dump (the sampled deadlock triangle); (4) the structural fix: the
environment-armed watchdog arms at the END of `import underworld3`,
never during it, trading self-coverage of the import graph for safety
everywhere the tool exists for (documented in
_watch_from_environment).

Validation: the previously first-run-reproducing context (piped
children after a fresh build, 0.2 s interval) went from 12/15 frozen
to clean in the test's own scenario; the actual test passes 6/6
locally. Known latent issue, pre-existing and out of scope: a natural
process exit while a repeat dump is in flight can hang finalisation
(the test SIGKILLs and never sees it).

Underworld development team with AI support from Claude Code
@lmoresi

lmoresi commented Aug 25, 2026

Copy link
Copy Markdown
Member Author

test_0054 root-caused and fixed (4b5ad9c). Native thread samples of two live hangs establish the mechanism: faulthandler.dump_traceback_later(repeat=True) walks live frames from its C thread without synchronisation, and against a still-importing interpreter that walk loops forever (the C thread pinned in dump_traceback for an entire sample window, the main thread starved mid-import) or reads garbage and dies at SIGSEGV — the CI -11. The environment-armed watchdog was arming during import underworld3.mpi, inside the very import it then dumped over. The trigger is conditional, which is why it read as flaky: it needs a slow import (cold bytecode caches — CI always, or locally straight after a build) and piped child output (exactly how the test runs); this branch's larger import graph pushed the import past the 1.0 s trigger where development stays under it.

Four hardenings, the last structural: the reporter thread pre-loads everything it touches and formats stacks without source-text reads (lookup_lines=False — file, line and function carry the hang report); the reporter re-arms only its own timer, since re-issuing dump_traceback_later cond-waits on the C thread mid-dump; and the environment-armed watchdog now arms at the END of import underworld3, trading self-coverage of the import graph for safety everywhere the tool is for. The previously first-run-reproducing context went from 12/15 frozen to clean under the test's own scenario, and the test passes 6/6 locally.

One latent issue noted for the record, pre-existing and untouched: a natural process exit while a repeat dump is in flight can hang finalisation (the atexit disarm waits on the dump). The test SIGKILLs its child and never sees it; an env-armed production run that exits during a dump tick could. Worth its own small issue.

CI re-running now.

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.

2 participants