Skip to content

Visualisation: principal-stress glyphs and stress trajectories - #601

Merged
lmoresi merged 6 commits into
developmentfrom
feature/stress-glyphs
Aug 24, 2026
Merged

Visualisation: principal-stress glyphs and stress trajectories#601
lmoresi merged 6 commits into
developmentfrom
feature/stress-glyphs

Conversation

@lmoresi

@lmoresi lmoresi commented Aug 18, 2026

Copy link
Copy Markdown
Member

What this adds

A way to see the stress tensor, in the same spirit as velocity arrows: sampled at seed points, not drawn everywhere.

  • principal_stress_glyphs(coords, stress, scale) — one bar per principal axis at each seed: length proportional to the principal-value magnitude, with a "tensile" cell array so the two signs colour separately (blue compressive, red tensile, matching the RdBu_r field convention). A cross in 2-D; three orthogonal bars in 3-D.
  • direction_trajectories(direction_at, seeds, inside, step, separation) — stress trajectories. A principal direction is defined only mod 180°, so ordinary streamline tools cannot integrate it; this integrator sign-aligns each evaluated eigenvector with the previous heading, and places lines evenly (Jobard–Lehmann occupancy with separate seed-blocking and line-stopping tests). 2-D only — in 3-D the analogue is a trajectory surface, which we do not attempt.
  • tensor_fn_to_pv_points, trajectories_to_pv_lines — the evaluation and bundling steps, mirroring the existing *_fn_to_pv_points helpers.
  • plot_stress_glyphs(mesh, stress, ...) — the one-call wrapper beside plot_vector. Default seeding is a grid over the bounding box filtered to points inside the mesh, so an annulus seeds nothing in its hole.

Docs page docs/advanced/stress-visualisation.md with worked figures (a blind-thrust fault network and a 3-D Stokes sinker, both rendered from checkpoints). It records two facts a user needs: the pressure datum is a gauge that can flip bar colours but never rotate axes, and World-Stress-Map-style regime colouring is degenerate in 2-D incompressible plane strain (the out-of-plane stress is always the intermediate principal stress), so it is deferred until there is a 3-D use case.

Tests

tests/test_0848_stress_glyphs.py (level_1, tier_a): glyph geometry for uniaxial compression, pure shear, and a 3-D diagonal tensor; the eigenvector sign-flip case that a naive integrator fails by reversing mid-line; trajectory separation; and the annulus default-seeding case end-to-end through plot_stress_glyphs. All pass in the worktree environment; the style gate is clean.

Underworld development team with AI support from Claude Code

Sample the stress tensor at seed points, the way velocity arrows
sample the velocity. principal_stress_glyphs draws one bar per
principal axis (blue compressive, red tensile, length = magnitude;
a cross in 2-D, three orthogonal bars in 3-D), and
direction_trajectories integrates the principal direction field -
defined only mod 180 degrees, so the integrator carries orientation
continuity and places lines evenly (Jobard-Lehmann occupancy).
plot_stress_glyphs is the one-call wrapper beside plot_vector, with
default seeding filtered to points inside the mesh (an annulus seeds
nothing in its hole).

Docs page (docs/advanced/stress-visualisation.md) covers the pressure
gauge caveat and why map-view regime colouring is degenerate in 2-D
plane strain. Tests cover the glyph geometry, the eigenvector
sign-flip case an ordinary streamline integrator gets wrong, and the
annulus seeding.

Underworld development team with AI support from Claude Code
Copilot AI lite review requested due to automatic review settings August 18, 2026 03:08

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.

The grid guess (extent / num_seeds) mis-sizes bars whenever the
caller passes section-plane seeds; use the mean nearest-neighbour
distance instead, subsampled to keep the pairwise matrix small.

Underworld development team with AI support from Claude Code
@lmoresi

lmoresi commented Aug 18, 2026

Copy link
Copy Markdown
Member Author

Adversarial review

We attacked the diff along the ways a glyph routine can silently draw the wrong thing.

Found and fixed (32b397d): with user-supplied seeds, the auto-scale estimated spacing as extent / num_seeds — a grid parameter the caller's seeds owe nothing to. A 13×13 section plane with the default num_seeds=24 drew bars at roughly half the intended size. Now estimated from the mean nearest-neighbour distance of the seeds themselves (subsampled above 2048 seeds to bound the pairwise matrix).

Checked and held:

  • Eigen-decomposition trusts one triangle. numpy.linalg.eigh reads only the lower triangle, so an asymmetric input (recovered components are not exactly symmetric) would be half-ignored silently. The builder symmetrises first; the docstring says why.
  • Mod-180° integration. A direction field's eigenvector sign is arbitrary; a naive integrator reverses mid-line and draws a folded stub. The test suite includes a field whose reported sign alternates underfoot (sin(20x) flip) and asserts the trajectory crosses the whole box without turning.
  • Occupancy split. Using one occupancy set for both seed-blocking and line-stopping chops trajectories into dashes (we hit exactly this in the prototype). The landed integrator keeps a wide corridor for seeds and a traversed-cell set for stopping, and a line claims its cells only after integrating so it never blocks itself.
  • Non-box domains. Default seeding on an annulus: the bounding-box grid is filtered by closest-cell distance, so the hole seeds nothing and evaluate is never called outside the mesh. Asserted end-to-end in test_annulus_default_seeds_avoid_the_hole.
  • Units. tensor_fn_to_pv_points strips Pint magnitudes before PyVista sees them and stashes the units string, matching the scalar/vector helpers.
  • Style gate clean; tests level_1/tier_a, 6/6 pass in the worktree environment.

Known limits, stated in the docs rather than papered over: closed trajectory orbits are traced once per integration sense (cosmetic overdraw); 3-D trajectories are deliberately out of scope (surfaces, not curves); the compressive/tensile split is relative to the pressure gauge, which can flip colours but never rotate axes.

A scalar MeshVariable's .sym is a 1x1 Matrix; the recipe as written
nested matrices. Caught by running the recipe against the fault
examples.

Underworld development team with AI support from Claude Code
The docstring promised a plotter callers could decorate, but show()
ran unconditionally first and a finalized scene ignores later actors
- overlays were dropped silently. With show=False the camera is set
and the plotter left open; callers add overlays and screenshot.
Caught by driving the 3-D sinker example through the wrapper.

Underworld development team with AI support from Claude Code
@lmoresi

lmoresi commented Aug 18, 2026

Copy link
Copy Markdown
Member Author

Validation against the fault-interaction examples

We drove the landed API end-to-end over the checkpointed fault-interaction models (nested listrics, en-echelon, blind thrusts, all at w = 0.01) and the 3-D sinker: tensor_fn_to_pv_points evaluates the recovered-stress Matrix at the seeds, principal_stress_glyphs builds the crosses, direction_trajectories + trajectories_to_pv_lines draw the trajectory nets, and the sinker goes through the one-call plot_stress_glyphs with section-plane seeds. The figures reproduce the pre-PR prototypes exactly. No solving anywhere — everything loads from read_timestep.

The exercise caught two more defects, both fixed:

  • 8f064dd — the docs recipe built the stress Matrix from Txx.sym, but a scalar variable's .sym is a 1×1 Matrix; the recipe as written nested matrices. Now indexed (Txx.sym[0]) with a note saying why.
  • 2479bb0plot_stress_glyphs promised a plotter callers could decorate, but ran show() unconditionally first, and a finalized scene ignores later actors: the sinker's box and sphere overlays were dropped silently. A show= parameter now leaves the plotter open (camera set) so overlays and screenshots work as documented.

Tests still 6/6 after both fixes.

The figures now come from the API demonstration script driving the
checkpoints: thrust, listric, and en-echelon each get the two-panel
crosses + trajectories treatment, with the strain-rate second
invariant behind at low opacity so the quiet wedges and relay lobes
read without competing with the glyphs. The sinker figure gains its
cube and sphere overlays via plot_stress_glyphs(show=False).

Underworld development team with AI support from Claude Code
Development CI is green; this branch hung from 90% to the 2-hour cap
(run 32097850915) with one worker lost to the VTK crash genre. The
annulus test was the first in CI to drive plot_stress_glyphs through
its default show=True, and show() on a headless runner can enter an
interactor wait. The test asserts what the plot builds, not what it
renders, so it now uses the show=False path and no render happens
anywhere in the file.

Underworld development team with AI support from Claude Code
@lmoresi

lmoresi commented Aug 18, 2026

Copy link
Copy Markdown
Member Author

CI: first run timed out — diagnosed and fixed (d69839d)

The first test run lost a worker at 78% (the intermittent VTK-render crash genre) and then hung from 90% until the 2-hour cap. Development is green on the same configuration, so the cause was in this branch: the annulus test drove plot_stress_glyphs through its default show=True, making it the first test in CI to call show() — which on a headless runner can enter an interactor wait and hang the xdist session.

The test now uses the show=False path, so nothing in the test file ever renders — the assertions are about what the plot builds (seed filtering, glyph geometry), not what it draws. This is the same discipline test_0847 established for plotter lifetime, extended to the render call itself.

@lmoresi

lmoresi commented Aug 18, 2026

Copy link
Copy Markdown
Member Author

Adversarial review

Reviewed at the PR head. Three findings, two checks that came back clean, and
one link to another open PR.

1. Degenerate points are the failure mode this integrator has, and nothing
covers them.
direction_trajectories sign-aligns each evaluated eigenvector
with the previous heading, and test_trajectory_survives_eigenvector_sign_flip
pins that. A sign flip is not the hard case. Where the two principal values
cross — an isotropic point, generic in a 2-D stress field, and exactly what
occurs between the fault tips in the figures on the docs page — the eigenvectors
are not merely sign-ambiguous but arbitrary in orientation, and continuity with
the previous heading cannot recover a direction that is undefined. A trajectory
passing near one will turn wherever eigh happens to point.

That is inherent to the object rather than a defect in the code, and it is worth
saying so on the docs page next to the figures a reader will draw conclusions
from. A test would be a stress field with a known isotropic point, asserting the
integrator terminates or flags rather than producing a smooth-looking line
through it.

2. np.linalg.eigh ordering is load-bearing and undocumented in the glyph
function.
The "most-tensile axis" convention depends on eigh returning
ascending eigenvalues. The tests encode the convention independently — the
oracle is the closed-form angle = 0.5 * arctan2(sxy, 0.5 * (sxx - syy)), which
is the right way to check it — but principal_stress_glyphs itself does not say
which end of lam is which, and a reader adding a third colour or reordering
bars has nothing to work from.

3. separation and step are given defaults (0.04, 0.008) in the docs
example with no statement of what they are relative to.
Both are lengths in
model units, so the defaults are implicitly tied to a domain of order 1. On an
annulus of radius 6371e3, or a unit box scaled to metres, they produce either one
line or several million steps. The wrapper could derive them from the mesh extent
rather than carrying absolute numbers.

Checked and cleared.

  • Symmetrisation is real. eigh reads one triangle, and the input is
    symmetrised at both entry points — 0.5 * (stress + np.transpose(stress, (0, 2, 1)))
    in the glyph path and the equivalent on stress_values. A non-symmetric input
    (a recovered stress that has not been symmetrised) will not silently produce
    eigenvectors of the wrong tensor.
  • The :alt: options are single-line. Each is one long line rather than
    wrapped, which is what MyST requires — a wrapped :alt: truncates the alt text
    and renders the remainder as a stray second caption. Worth stating because the
    alt text here is unusually long and the temptation to wrap it is real.

Link to #602. tensor_fn_to_pv_points samples through
uw.function.evaluate, not global_evaluate, so #602's non-finite recovery does
not reach this code. If the trajectory integrator is ever run in parallel it will
want the global path, and at that point #602's substitution of an RBF
extrapolation for a failed interpolation becomes a substitution inside a figure —
worth remembering rather than acting on now.

Underworld development team with AI support from Claude Code

lmoresi added a commit that referenced this pull request Aug 24, 2026
…#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
@lmoresi
lmoresi merged commit 09b032d into development Aug 24, 2026
2 checks passed
lmoresi added a commit that referenced this pull request Aug 26, 2026
…grid (#629) (#638)

* The general 3-D outcrop cap: per-region collar, crease overlay, smooth 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

* Tests: the general 3-D outcrop on a rotated box, across a box edge, on 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

* A relabel refusal must be collective

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

* place_sheet and remove_embedded conserve the domain's Euler number

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 assembly volume gate sits above OCC's boolean-mass noise

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

* The boundary snap covers OCC's placement noise, masked to the facets

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

* The 3-D imprint collapse; on-crease outline edges bound the far collar

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

* place_sheet clips, carves and caps against the mesh's own boundary

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 daylighting acceptance test: slip at the trace, gated on the split

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 snap test's control sits above the widened tolerance

The boundary snap's tolerance was raised to 1e-6 to cover OCC's ~4e-7
boolean placement noise (34ea1928), 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

* Faults reach daylight blind, under a damage zone — the acceptance test

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

* place_sheet stops short on request: setback clips against the offset 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

* The concavity gate measures depth against the setback; the trace locator 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

* The sheet's resolution is a mesh choice: place_sheet(size=) re-triangulates

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

* Point-to-sheet sweeps cost the near-band, not the domain (#613)

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

* edge_split adapt reads each pass's topology once (#610 tier 1)

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

* MG parent maps are built for the retained levels, not per pass (#610)

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

* adapt-on-top skill: the h_far-below-base-diameters trap

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

* cells_supporting reads a facet's support directly, in any dimension (#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

* add_conforming_sheet: the 3-D cut-at-the-finest-level, with the tail

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

* The fault-network place route inherits the adapt hierarchy

_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

* The place route's recorded pathology re-measured: cell count, not operator 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

* Skip the custom-P re-install when the hierarchy is already live (#622)

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

* The Stokes OUTER Krylov is fgmres: the flexible-outer rule reached the 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

* custom_mg's rbf builder is the standard sparse local interpolator (#429), 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

* The rotated PCMG coarse solve is SVD only when rotation modes exist (#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

* Mesh._cell_node_indices: which DOF rows belong to which cell

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

* Exact nested transfers for native refine() pairs, structurally-clean 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

* FAC strong patch solves restore contrast-independent V-cycles (#629 item 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

* A physics-keyed strong-patch mode: UW_FAC_PATCH=slit takes the split-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

* Slit patches detect coincident FINE pairs, and split into per-segment ASM blocks (#629)

Two corrections from the 2-D campaign. The slit detection keyed on
transfer identity rows onto a coarse node — empty for a cut mesh, whose
slit vertices are NEW points at edge crossings coinciding with no coarse
node, so the mode silently declined to whole-level smoothing (caught by
a k-sweep flat at plain-FMG counts with no ASM in the probe). The trace
is now the coincident FINE pairs — the split's plus/minus nodes at
bit-identical coordinates — with the transfer-dup rule kept as a union.

A patch entry may now be a LIST of (owned, subdomain) blocks and
_configure_pcmg installs one ASM subdomain per block; UW_FAC_SEGMENTS=k
splits the trace into k along-strike chunks (by the coordinate along the
trace's leading principal component) for the segmentation experiment.

Measured on the 2-D rig (contrast 1e4, shifted-LU blocks, overlap 1):
7 velocity iterations at k = 1, 2, 4, 8, 16 — block-wise segment solving
is FREE; across-fault stiffness is block-local and the along-fault mode
is smooth, so the ordinary coarse ladder carries it. k=16 at contrast
1e6: still 7. And with a strong lid (eta 1e4, jump OR smooth profile)
crossed by the 1e-4 gouge — 1e8 across the fault walls — still 7/7,
against GAMG 88/80 and patchless FMG 16/16: the extended co-dim-1 lid
jump is benign for the Galerkin ladder; the malignant sharp feature is
the thin cross-cutting gouge, which is exactly what the patch owns.

Underworld development team with AI support from Claude Code

* Zone-keyed strong patches: painted fault models need no split to be patch-solved (#629)

The patch SOLVE is rheology-agnostic — ASM blocks + shifted LU over a
row set, blind to the constitutive model — and only the DETECTION was
split-specific (coincident fine pairs). A painted weak or TI band has no
topology to detect and needs none: the modeler painted the cells. A
boolean cell mask on the solver (solver._fac_zone_cells) now keys the
finest level's patch to those cells' DOFs directly.

Measured on the 2-D rig, UNSPLIT painted weak band (the weak-fault
model, standard Stokes path): zone-patched FMG holds 9 its at contrast
1e4 and 7 at 1e6 against GAMG's 40-and-degrading — the same constancy
the split-contact runs show, with no split anywhere. The fault
representation (split / weak / TI) returns to being a physics choice;
the solver architecture is indifferent to it.

Underworld development team with AI support from Claude Code

* The finest patch always contains the STRUCTURAL patch: zone blocks union the non-identity rows (#629)

The patch smoother REPLACES whole-level smoothing, so it inherits every
row the coarse level cannot represent — the cut/split-inserted DOFs the
transfer's non-identity rows identify — whether or not the physics zone
covers them. A zone away from the cut left those rows smoothed nowhere:
the velocity sub-solve capped at 200 iterations on every application
(measured on a V-junction, on parallel strips, and on a single band
crossing the cut, while zones ALONG the cut worked by accident of
geometry; the overlap-0 slit stagnation was the same omission). The
zone-block builder now appends the uncovered structural rows as one
more ASM block.

Confirmed: V-junction 8 its at contrast 1e2 and 1e4, merged or
per-segment blocks; parallel strips 8 its — all previously stagnant,
and the junction itself is a NON-EVENT. The pressure sub-solve also
returns to 14 iterations from its 200 cap once the velocity PC is
whole: part of the recorded pressure-cap pathology (#625) is downstream
of an incomplete velocity preconditioner.

Underworld development team with AI support from Claude Code

* Design note: fault-patch multigrid — the measured skeleton and parallel rules for 3-D (#629)

The architecture (ordinary multigrid + physics-keyed strong patches),
the evidence, the configuration rules each learned from a failure —
led by the structural-patch rule — the scaling statement, and the
parallel design rules for the 3-D deployment.

Underworld development team with AI support from Claude Code

* The ribbon is part of the FMG (design ruling, #629)

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

* Pair co-residency is automatic under local-frame splitting (design correction, #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

* What the fault ribbon is for (design ruling, #629)

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

* The meaning of w follows from the fault representation (design clarification, #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

* #629 productionizing: fac_zone API, #589 fixed at source, ladder band 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

* The patch-keying ruling: fac_zone is for volumetric fault zones only (#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

* The 3-D ladder band: extruded prism-tets from the fault sheet, no remesh (#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

* place_fault_ribbon: the one-call fault-ribbon production path (#629)

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

* Correction + re-baseline: the rotated transfer cache exists; the warm 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

* The AL penalty pays at gamma=1 on the ladder stack (#629, the #625 mechanism)

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

* place_fault_ribbon honours the requested fault: the tip margin is extrapolated, 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

* The curved 2-D ladder: numpy rails for bent traces, nesting by shared 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

* place_fault_ribbon_2d: the S-fault rig's fault-network prep, one call (#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

* uw-visualisation skill: grid-resampling dapples at element boundaries — 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

* Review fixes for #638: the honoured-paint rule becomes API; duplicate 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

* CI fixes for #638: the gated coarse-solve semantics in test_1021; worst-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

* The environment-armed watchdog arms after import, and the reporter never 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
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