Skip to content

Subduction phase-change rule engine (phase_change_option 3) on a VirtualTopology marker grid - #110

Merged
chaseshyu merged 32 commits into
masterfrom
feature/virtual-topology
Oct 30, 2026
Merged

chaseshyu merged 32 commits into
masterfrom
feature/virtual-topology

Conversation

@chaseshyu

@chaseshyu chaseshyu commented Oct 25, 2026 •

Copy link
Copy Markdown
Member

Why

DES's subduction rules, phase_change_option = 1, hard-code materials 0–7 and serpentinize only
elements with hydrous markers. geoflac's rules (eclogite, mantle
⇄ serpentinite, chlorite-out, the sediment chain) had no DES port. They serpentinize mantle above
subducted wet crust (oceanic crust, sediment or sedimentary rock), which needs a marker-cell index
that follows the slab and that each checkpoint carries. This PR adds the index, VirtualTopology,
and the rule engine on it, phase_change_option = 3, with depth measured below the current surface.

What

31 commits, each message with its why and what; (10) is the 10th, in the order of the Commits tab.
C++: +1886 −101, mostly phasechanges.cxx and mesh.cxx/mesh.hpp. A regression gate for option 3
follows as its own PR.

Part Commits Content Changes runs without option 3
1 1–6 Fixes of code that predates this PR: phase-change input check (1), option 1 without hydration (2), averaged output (3, 4), lumped masses after phase changes (5), compare.py marker z in 3D (6) 3, 4 (output only), 5 (options 1, 101), 6
2 7–9 Two keys renamed (7), depth below the surface without the attenuation table (8), VirtualTopology: a marker-cell index on a background grid, checkpointed (9) 7 (key names)
3 10–11 The rule engine (10): a cell is wet crust above half its markers; examples/subduction.cfg (11) —
4 12–17 Speed-ups (12–15), no lock (16), marker passes on the GPU (17); bit-identical on CPU —
5 18–28 Review revisions: the "wet crust" name (18); surface plastic strain decays in every top element (19) and the eruption's leftovers go (20); unset or shared material indices stop at input (21); a restart with another grid cell size rebuilds the grid (22); the grid is rebuilt at each quality check (23), stored in one array (24) and ends below the deepest mantle that can serpentinize (25, 26); phase changes run before update_mesh() (27); a temporary repair of the bottom-snap fold (28) 19 (2D), 21 (input), 27 (options 1, 101), 28 (only a run that would stop)
6 29–30 Second review round: the pressure the rules read is a key (29); the serpentinization depth cap and the wet-crust fraction are keys (30) —
— 31 The CHANGELOG entry —

Breaking:

  • mat.mattype_crust → mat.mattype_continental_crust, mat.mattype_sed → mat.mattype_sediment;
    an old name stops at input with exit 11 (7).
  • mat.mattype_continental_crust and mat.mattype_oceanic_crust default to −1 (unset):
    ic.temperature_option = 1, or an active option-3 rule that reads one, stops at input with
    exit 11 until it is set (21).
  • In 2D, surface plastic strain decays in every top element; it skipped oceanic crust, a remnant of
    the mid-ocean-ridge eruption that Enable GPU acceleration and parallelization across Dynearthsol #25 removed. 2D runs with oceanic crust at the surface change (19).
  • Averaged output (the default) divides by the steps of its window: about 1 % at the default
    mesh.quality_check_step_interval; with an interval of 1 it is instantaneous (3, 4). Physics
    unchanged.
  • Runs with phase_change_option ≠ 0: conversions read the positions at the start of the step,
    and the lumped masses are rebuilt from the converted composition (5, 27). Options 1 and 101
    change from their first conversion; on a static mesh (control.has_moving_mesh = no,
    control.rsf_dtheta_max = 0), from step 10.

For the reviewer

  • Feedback wanted: a check of the review revisions (parts 5 and 6), and a physics check of the
    rule table. The three open questions are answered:
    1. Pressure: selectable, mat.phase_change_pressure_option (29). 0 (default) = the element mean
      stress, 1 = ref_pressure() at the depth below the local surface.
    2. Constants: keys, mat.serpentinization_max_depth (150e3; it also ends the grid) and
      mat.wet_crust_fraction_threshold (0.5) (30).
    3. Static-mesh mass rebuild: kept, because density changes with temperature too.
  • Start at: phasechanges.cxx, subduction_phase_changes() and build_phase_transition_rules();
    then the VirtualTopology overview in mesh.hpp.
  • Check closely:
    1. antigorite_stable() bounds both serpentinite rules; its low-pressure point (650 °C, 0.2 GPa)
      replaces geoflac's (670 °C, 0.6 GPa) in the mantle rule. With geoflac's two fields, mantle at
      642–730 °C between the low-pressure lines serpentinizes and dehydrates back at every call; one
      field leaves no pressure and temperature where both rules apply.
    2. The grid is rebuilt at each quality check, right after update_mesh(): markers are added or
      removed only there and in a remesh, which rebuilds it too, so the grid indexes every marker.
      An assert stops the run (exit 60) if a marker count or id changes between rebuilds. A remesh
      step builds the grid twice.
    3. (28) is temporary: the full fix, bc.has_winkler_bottom_restore, comes in a later release.
      It acts only when MMG returns a mesh the existing tangle check would stop on.
    4. The OpenACC passes of (17) have no data clauses: they rely on mem:managed, which every
      openacc=1 build sets.

Try it

  • Default: phase_change_option = 0. With 3, a rule runs when its mat.has_* switch is on; its
    source and product must then name distinct, set materials, or the run stops at input (exit 11).
    Serpentinization (on) needs mat.mattype_serpentinite and mat.mattype_sediment;
    eclogitization (on) needs mat.mattype_oceanic_crust and mat.mattype_eclogite.
  • Keys that change it:
    • mat.serpentinization_thickness (10 km): raise it for a thicker serpentinite band.
    • mesh.vt_size (0.5 × mesh.resolution): lower it for a sharper slab outline, while cells still
      hold markers.
    • mat.serpentinization_wet_crust_margin (−1, off): set it ≥ 0 to drop false positives under a
      slab bend.
    • mat.phase_change_pressure_option (0, the element mean stress): set it to 1 for the
      reference lithostat that the phase diagrams use.
    • mat.serpentinization_max_depth (150 km) and mat.wet_crust_fraction_threshold (0.5):
      untuned.
  • Start from: examples/subduction.cfg (2D, usemmg=1; 5 Myr in about 22 min on 8 threads of a
    TACC Vista node); it lists every option-3 key, most with a comment.

Numerical effect

  • Effect: intended. Option 3 is new: (12)–(17) keep its results (CPU; on a GPU, (17) matches
    (16)); part 5 changes them as the review asked; part 6 keeps them bit-exact at the defaults of
    its new keys. Runs without option 3 change only as Breaking lists; option 0 physics is bit-exact but for the 2D surface plastic strain of (19).
  • Reproduce: make ndims=2 usemmg=1, then in examples/ OMP_NUM_THREADS=8 ../dynearthsol2d subduction.cfg: frame 100 (5 Myr) holds 3292 serpentinite (material 4) and 392 eclogite
    (material 5) markers (g++ 14.2.0 on Vista). Another compiler gives other counts.
  • Cost: phase changes take 2.79 % of a 21 min 34 s run of examples/subduction.cfg on 8 threads
    of a TACC Vista node; (27) cut the time per step by 3 %. On a busy Apple M3 Max with 6 threads,
    the same run takes 28 min 56 s, and phase changes take 4.43 %.
Cost per part, and the host

From the end-of-run time summary (also in the .manifest): 5.78 M steps, 220 remeshes, 1294 s in
all. Option 3 runs every 10 steps.

Part Time Share of the run Per call
Phase changes, both marker passes 36.1 s 2.79 % 0.062 ms, 578 000 calls
Remesh with MMG, including its grid rebuild 7.2 s 0.55 % 33 ms, 220 remeshes
Output, including the grid in checkpoints 6.1 s 0.47 % 60 ms, 101 frames
Grid rebuild at each quality check no timer of its own: inside Compute (96.19 %) — 5780 calls

Host, from the run's .manifest: a TACC Vista CPU node (NVIDIA Grace, Arm Neoverse-V2; Rocky Linux
9.7; g++ 14.2.0), 8 OpenMP threads pinned to one 8-core block (OMP_PLACES=cores,
OMP_PROC_BIND=close); other runs used other cores of the node. (27): 214 → 207 µs per step, two
repeats each, on the same node.

The same run on an Apple M3 Max (12 performance + 4 efficiency cores; macOS 26.6; Apple clang 21),
6 OpenMP threads, not pinned: 28 min 56 s, 5.89 M steps, 279 µs per step. Phase changes took 4.43 %
(0.131 ms per call), remeshing 0.47 % (200 remeshes) and output 0.38 %. Other jobs shared the Mac
during the run: the 1-minute load average was 14–20 on its 16 cores (mean 16), so an idle M3 Max
runs faster. The step and remesh counts differ from Vista's because another compiler gives other
round-off.

Checks of the second review round (29)–(30)

Check Result
(29) alone and the tip, ndims 2/3, usemmg=1 hdf5=1, Apple clang; the tip also with usemmg=0 builds
Tip on the follow-up PR's gate (2D), against pins of (28)'s binary pass; all compared fields bit-exact. Two new items check pressure option 1: the hot case at the edge of the antigorite field, with the pressure recomputed as the lithostat (15 band markers, 82 serpentinite, as option 0), and gravity off, where the lithostat is 0 (no serpentinite; option 0 makes 133)
The two new items against broken builds a build that negates the depth leaves 133 wedge mantle markers; a build whose option 1 still reads the mean stress makes 133 serpentinite; each fails its item
A pressure option other than 0 or 1, a depth cap ≤ 0, a fraction outside [0, 1) each stops at input, exit 11
examples/subduction.cfg to 1 Myr, pressure option 0 against 1 (Apple M3 Max, 6 threads) serpentinite 704 and 741, eclogite 45 and 23 markers; one pair of runs, which diverge

Not run for (29)–(30): g++, nvc++ openacc=1, and the 3D gate.

Checks of the review revisions (18)–(28)
Check Result
Each commit, ndims 2/3, usemmg=1 hdf5=1, Apple clang ((25) in 3D together with (26); (28) in 3D with g++ 14.2.0 only) builds
Each commit with its own CPU binaries, on the follow-up PR's gate (2D), now also with restarts at another grid cell size, input checks and the grid's depth limit pass; option 0 cases (test-tiny, test-topo, test-core, test-rect-tiny) bit-exact but at (19), which changes 2D surface plastic strain as intended (test-rect-tiny: two top elements; test-core); option-3 results re-pinned only where the commit intends a change
Tip, g++ 14.2.0 on Vista: both gates (2D and 3D) pass
(25), (27), (28): examples/subduction.cfg to 5 Myr on Vista, against the commit before (25): serpentinite −27 % at 250 kyr and −7 % at 5 Myr, as the grid rows now sit at fixed depths below the surface; (27): within a few % up to 3.5 Myr; (28): the run without it stops at 3.7 Myr (exit 42), with it reaches 5 Myr, and a restart from 3.5 Myr matches the fresh run exactly
Checks of the first round (1)–(17)
Check Result
Every commit, ndims 2/3, usemmg=1 hdf5=1, Apple clang and gcc 8.5 builds
(10)–(17), nvc++ openacc=1, ndims 2/3 builds
(1)–(9): test-rect-tiny bit-exact against master
(10)–(17), each with its own CPU binaries, on the follow-up PR's gate. 2D: test-rect-tiny without option 3; a small subduction model with its grid checked against the markers in every checkpoint, restarts after a remesh, with the crust margin on and from a conversion step, rule checks, a hot case at the edge of the antigorite field, and gravity off to check the pressure source. 3D: grid checks and a restart across a remesh pass; all compared fields bit-exact against one reference set
(17) against (16) on a GPU (RTX A5000): examples/subduction.cfg to 100 and 500 kyr bit-exact frames

Demo: examples/subduction.cfg to 5 Myr (g++ 14.2.0 on Vista, 8 threads, 22 min)

398_vt_example_evolution

The slab at 0-4 Myr: the mesh with each element filled by its main material, isotherms, the 3 km
VirtualTopology grid, its wet-crust cells (orange: more than half oceanic crust, sediment or
sedimentary rock) and the cells the 5 km search reaches. As the slab sinks to about 110 km, mantle
serpentinizes just above its wet-crust cells, along the slab top and in the wedge corner, and
eclogite forms at its tip.

375_vt_grid_vs_mesh_crust50_f100

At 5 Myr. Top left: the same map. Right: a zoom on the slab top with the markers, and its plastic
strain with the same isotherms. Bottom, through the run: the element count with each remesh's
change (the jump at 3.7 Myr is the remesh (28) repairs), the 220 remeshes by trigger (bottom drift
early, then low quality and tiny elements), and the serpentinite and eclogite markers.

Demo before the review revisions: examples/subduction.cfg to 5 Myr (Apple clang, 6 threads, 20 min) 398_vt_example_evolution

The slab at 0-4 Myr: the mesh with each element filled by its main material, isotherms, the 3 km
VirtualTopology grid, its crust cells (orange: more than half oceanic crust or sediment) and the
cells the 5 km search reaches. As the slab sinks to about 110 km, mantle serpentinizes just above
its crust cells, along the slab top and in the wedge corner, and eclogite forms at its tip.

375_vt_grid_vs_mesh_crust50_f100

At 5 Myr. Top left: the same map. Right: a zoom on the slab top with the markers, and its plastic
strain with the same isotherms. Bottom, through the run: the element count with each remesh's
change, the 206 remeshes by trigger (bottom drift early, then low quality and tiny elements), and
the serpentinite and eclogite markers.

New or retired names

  • Added: option 3 and its 16 keys (list below); a grid record in option-3 checkpoints (a corrupt one
    stops a restart with exit 22; a checkpoint without one still restarts, and a restart with another
    grid cell size warns and rebuilds the grid); a PhaseChg: line in the time summary and
    phase_change_* fields in the manifest (option 3). No new exit codes.
  • Renamed or retired: the two keys under Breaking. --help drops option 2, the mid-ocean-ridge
    rule that Enable GPU acceleration and parallelization across Dynearthsol #25 removed, and marks its four keys unused; they are still accepted (1).
The 16 keys of option 3 with defaults, and the four unused keys
  • Materials, −1 = unset (a rule on that reads an unset one stops at input):
    mat.mattype_serpentinite, mat.mattype_eclogite, mat.mattype_hydrous_mantle,
    mat.mattype_sediment_rock, mat.mattype_metasediment, mat.mattype_schist.
  • Switches: mat.has_serpentinization (yes), mat.has_eclogitization (yes),
    mat.has_hydrous_mantle_dehydration (no), mat.has_sediment_metamorphism (no).
  • Pressure: mat.phase_change_pressure_option (0, the element mean stress; 1, ref_pressure()).
  • Grid: mat.serpentinization_thickness (10e3 m), mat.serpentinization_wet_crust_margin (−1),
    mat.serpentinization_max_depth (150e3 m), mat.wet_crust_fraction_threshold (0.5),
    mesh.vt_size (0.5). It counts mat.mattype_oceanic_crust, mat.mattype_sediment and
    mat.mattype_sediment_rock (when set) as wet crust.
  • Unused since Enable GPU acceleration and parallelization across Dynearthsol #25, still accepted: mat.mattype_depleted_mantle, mat.mattype_partial_melting_mantle,
    mat.mattype_mor_extrusion, mat.convert_rate_oceanic_crust.

Checklist

  • Follows CONTRIBUTING.md; builds for ndims=2 and ndims=3:
    yes, every commit.
  • Entry under ## [Unreleased] in CHANGELOG.md: yes (31).
  • New parameters registered in input.cxx and documented in examples/defaults.cfg: yes.
  • Test under tests/functional/ or benchmarks-cores/: in the follow-up PR.

AI disclosure

Claude Opus 5.5 co-authored every commit, ran the checks and subagent reviews, restructured the
history and drafted this text.

🤖 Generated with Claude Code

chaseshyu and others added 9 commits October 25, 2026 15:38
phase_changes() implements only options 0, 1 and 101. Any other value
passed input, wrote frame 0 and died at the first phase-change step.
--help still listed option 2 and named classes that do not exist.

validate_parameters now rejects any other value with exit 11. The check
in phase_changes() cannot fire from a cfg any more, so it exits 61, an
internal error. The help names the functions behind options 1 and 101,
marks four keys of the removed mid-ocean-ridge rule as unused, and
names the real use of mattype_oceanic_crust in bc.cxx.

Option 0 is bit-identical (test-rect-tiny). test-3d-equ-big.cfg sets
option 2 and now exits 11 at input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
simple_subduction() read *var.hydrous_elemmarkers on every call, but that
marker set exists only with control.has_hydration_processes, so option 1
without it dereferenced a null pointer at its first phase-change step and
crashed (exit 139). Only the mantle -> serpentinized mantle rule reads it;
it now reads it only with hydration on.

With hydration on, option 1 is bit-identical; with it off, a run that
crashed now runs, and no marker serpentinizes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With mesh.quality_check_step_interval = 1 and the default averaged output,
the averaged buffers stayed empty: every frame after the first carried
zero-length rate and averaged arrays, dt_sec held the elapsed model time,
and a restart from such a frame died (exit 21). Averaging is now off for
that interval, so its frames carry the instantaneous fields and the step's
dt. Other intervals are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An averaging window holds N steps (N = mesh.quality_check_step_interval).
The output divided its N stress samples by N + 1, counted the snapshot
step's plastic increment in a rate over the N - 1 steps after it, and
divided that span by N for dt_sec. Each now uses its own count.

Averaged output is on by default, so every run with N > 1 sees stress
averaged grow by (N+1)/N, and dt_sec, the .info dt column and the screen
dt by N/(N-1); plastic strain-rate loses the snapshot step's increment
(each about 1% at the default N = 100). The physics does not change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On a slow-update step, phase_changes() converts markers after the
lumped masses in use were built. The run marches on the old masses, but
a restart from that step rebuilds them from the new composition. So a
checkpoint on a step with conversions does not reproduce the run.

compute_mass() now runs after phase_changes() when a phase-change
option is on. Option 0 stays bit-identical. With phase_change_option 3,
which this series adds later, a restart from a step with conversions
diverged by 5e-12 relative in temperature; now it is bit-exact in 2D
and 3D. On a moving mesh, option 1 changes from its first conversion.
On a static mesh without rsf_dtheta_max > 0, the time loop rebuilt no
masses, so options 1 and 101 change from the first slow-update step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
read_data() took marker column 1 as Z. In 3D that column is y, so a 3D
compare never compared the markers' depth. It now takes the last column,
as the node fields do. In 2D it is the same column, so 2D compares are
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mattype_crust and mattype_sed are ambiguous beside mattype_oceanic_crust,
and phase_change_option 3, later in this series, adds mattype_sediment_rock.
They become mattype_continental_crust and mattype_sediment: the Mat fields,
the options, validate_parameters' range-check table, the help texts and
every access, and every example, benchmark and test cfg follows. Results
are bit-identical.

BREAKING: a cfg using mattype_crust or mattype_sed is rejected; rename the
keys to mattype_continental_crust and mattype_sediment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The SPR remap centers its reference pressure on ref_pressure(zeff), with
zeff from SurfaceTopo's depth-attenuated table. A caller that needs only
the depth below the local surface does not need that table, and should
not pay for it on every build. build() now takes elev_only (default
false) to skip it. build(param, var) still builds the full table, as on
master, so the two SPR call sites in remeshing.cxx do not change. No
caller passes elev_only yet, so results are bit-exact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Serpentinization must find the mantle markers within a set thickness
above oceanic crust. The marker set has no spatial index for that.

VirtualTopology bins marker ids into a background Cartesian grid. It
keeps a build-time cell label per marker, the crust-dominant cells and
their optional crust extents. remove_markers() flags each main-set id
it back-fills or retires, so a reused id never reads a dead marker's
entry. The checkpoint carries the index, so a restart keeps the lagged
binning. A corrupt index dies at restart (exit 22). The next commit
adds the caller and the option-3 keys, and gates the restore on
uses_serpentinization_grid().

Numerical effect: none, as nothing builds the grid yet. With the next
commit, a restart across a remesh is bit-identical to a continuous run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 25, 2026 09:54

This comment was marked as off-topic.

chaseshyu and others added 8 commits October 25, 2026 22:13
Option 1, the only subduction rule set, hard-codes materials 0-7 and
serpentinizes mantle only in elements that hold hydrous markers.

Option 3 ports geoflac's change_phase as a table of P-T rules. A rule
acts only when its mat.has_* switch is on and its target mat.mattype_*
names a distinct material. The rules read the mean stress of the
marker's element as pressure, so the model's tectonic over- and
underpressure counts (geoflac reads a fixed-density lithostat), and
depth from the current surface. Mantle inside the antigorite stability
wedge serpentinizes when the VirtualTopology grid holds a crust cell
below it within mat.serpentinization_thickness: one where more than half
the markers are oceanic crust or sediment. GeoFLAC master looks for
oceanic crust or sedimentary rock in 80% of an element; on these
half-element cells 80% drops the cell at the slab top. Both serpentinite
rules read that one wedge, so no mantle serpentinizes where serpentinite
would dehydrate; geoflac's mantle rule uses a wider window, whose low-P
point (670 C, 0.6 GPa) reaches into that region. A cfg on which a rule
would misfire or never fire dies at input (exit 11). The time summary and
the manifest time the phase changes apart from compute.

Options 0, 1 and 101 are bit-identical; option 3 is new.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No example exercises mat.phase_change_option 3. This commit adds
subduction.cfg and poly-subduction.poly. The oceanic slab starts in the
mantle, so the serpentinization and eclogitization rules convert markers
early. A small domain and inertial_scaling 1e4 keep the run short. It
stops at 5 Myr, before the slab tip reaches the model bottom (about
7.8 Myr). It changes no code, so all results stay bit-exact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every call computed the coordinate of all markers and transformed it to the
grid's (x, zeff) space in a separate parallel pass, though only the markers
that reach the grid read it, and get_ZPT(topo) computes the same coordinate
and zeff for each of them anyway (same node order, so the same bits). The
overload now returns it, and the all-marker pass, its array and its acc wait
go. A probe of the subduction example put that pass at 39 of the call's
133 us. Output is bit-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pass 1 interpolated the position and temperature and read the pressure of
every non-mantle marker before it looked up its material's rule. A
material with no active rule (in the subduction example: sediment and
eclogite) cannot convert, so pass 1 now finds the rule first and skips
those markers. A probe put 1387 of the 5463 pass-1 markers per call in
that group. Output is bit-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pass 2 evaluated temperature and pressure for every mantle marker before
it looked for crust below, which most mantle markers lack. A labelled
marker with the crust margin off now checks the grid first, so those
markers skip the P-T evaluation; the live-cell fallback and the margin
walk keep the old order. Output is bit-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Walking the grid before get_ZPT made every mantle marker pay the whole
walk down serpentinization_thickness. At 30 km (10 cells) this cost more
than it saved: PhaseChg took 0.227 ms per call, against 0.210 ms with
get_ZPT first. Between the passes, one thread now flags every cell that
has a crust-dominant cell within the thickness below it. This work
scales with the dominant cells, not the mantle markers. Pass 2 reads the
flag at a labelled marker's cell before get_ZPT. With the crust margin
off, the flag is the whole answer; with it on, it prefilters the margin
walk. Pass 1 continues past a marker its rule rejects, so the -1
sentinel in `to` and a to != mt test that could never fail go.

Output is bit-identical. At 30 km, PhaseChg falls to 0.054 ms per call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The mutex and its 8 lock sites served one concurrent caller:
invalidate_dominant_cell() from pass 1 of subduction_phase_changes().
Every other mutator, and serialize(), runs in serial code. Pass 1 now
records each cell where a crust marker turns non-crust (one push_back per
such marker, in a critical section). The omp single between the
passes drops those cells from dominant_cells_ just before it builds the
crust-below map. It built the map after pass 1's barrier anyway, so pass 2
sees the same set, and the class takes no lock. Output is bit-identical
at one thread, and in every frame at 6 threads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In a GPU build, subduction_phase_changes() ran both marker passes on the
host's OpenMP threads, over managed memory: 13% of the first 100 kyr of
examples/subduction.cfg on an A5000. Each pass is now one element loop
with the paired omp/acc pragmas. Rules dispatch on a TransitionKind,
dominance and crust extents are flat per-cell arrays, pass 1 flags the
cells whose crust it converts, and elev() no longer calls
std::upper_bound. The checkpoint layout is unchanged; a restart now
refuses a non-finite crust extent. The first 100 kyr now take 3:01
instead of 3:43 (PhaseChg 3.36 to 1.29 ms per call). Bit-identical in
2D on CPU and GPU, fresh and restarted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chaseshyu
chaseshyu force-pushed the feature/virtual-topology branch from 5c51fa7 to e0cbefd Compare October 25, 2026 14:17
@chaseshyu chaseshyu changed the title Feature/virtual topology Subduction phase-change rule engine (phase_change_option 3) on a VirtualTopology marker grid Oct 25, 2026
chaseshyu added a commit that referenced this pull request Oct 25, 2026
The Unreleased section gains this branch's major user-visible changes:
phase_change_option 3 under Added, and the renamed material keys and the
changed averaged output and phase-change masses as Breaking entries, each
pointing to PR #110, with its link definition. The smaller fixes stay in
the pull request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
chaseshyu added a commit that referenced this pull request Oct 25, 2026
The Unreleased section gains this branch's major user-visible changes:
the VirtualTopology grid and phase_change_option 3 under Added, the
renamed material keys as a Breaking entry, and the averaged-output and
phase-change mass fixes under Fixed, each pointing to PR #110, with its
link definition. The smaller fixes stay in the pull request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chaseshyu
chaseshyu force-pushed the feature/virtual-topology branch from ee1e844 to 0df59fc Compare October 25, 2026 14:50
@chaseshyu
chaseshyu requested review from LucLavier, echoi, sungho91 and tan2 and removed request for LucLavier and sungho91 October 25, 2026 15:27
chaseshyu and others added 9 commits October 28, 2026 01:15
The 100-yr decay of plastic strain in top elements skipped those where
mattype_oceanic_crust is the most common marker material. The skip came
with the mid-ocean-ridge eruption, which put new oceanic crust on the
surface, and outlived it: since the eruption was removed, nothing
deposits oceanic crust at the top, and the skip only exempted whatever
material index mattype_oceanic_crust held -- 0 by default, often the
only material. It also made the default of mattype_oceanic_crust change
results in every 2D run. Raised in the PR #110 review.

Behavior change, 2D only (3D does not call the decay): plastic strain in
top elements whose most common material is mattype_oceanic_crust now
decays like the rest. test-rect-tiny: the 2 top elements with plastic
strain fall by (1 - ln2 dt / 100 yr)^2, nothing else moves; test-core
drifts from there; test-tiny, test-topo and test-vt-tiny stay bit-exact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
surfinfo.dh_oc was still allocated at init and at every remesh, and the
terrigenous report still declared max_dh_oc, although nothing has filled
or read either since the eruption that wrote them was removed. No
behavior change (vt-t0, test-tiny, test-topo, test-core bit-exact).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Asked in the PR #110 review (section 5).

mattype_continental_crust and mattype_oceanic_crust default to -1
(unset) instead of 0, which named the same material as the other base
defaults. Each now has one reader: the continental geotherm
(ic.temperature_option 1), which dies at input when the index is not
set, and phase_change_option 3. Both leave the base range check.

Under phase_change_option 3, a has_* switch that is on used to drop its
rule silently when its source or target was unset, out of range, or the
same material -- with has_eclogitization on by default, an unset
mattype_eclogite or mattype_oceanic_crust meant no eclogite and no
message. check_phase_transition_rules() now dies on each of these. It
also dies when mattype_sediment equals mattype_oceanic_crust while
eclogitization is on, since deposited sediment would turn into
eclogite. phase_change_keys() lists mattype_mantle once.

examples/subduction.cfg drops its mattype_continental_crust placeholder
and names the machine behind its run time.

Breaking: an option-3 cfg with has_eclogitization on (the default) must
set mattype_oceanic_crust and mattype_eclogite to two distinct
materials, and mattype_sediment to another one; a cfg with
ic.temperature_option 1 must set mattype_continental_crust. No tracked
cfg is affected. Results unchanged (vt-t0, test-tiny, test-topo,
test-core bit-exact).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
restart() restored the checkpoint's grid with its cell size and kept it
until the next remesh, although the input check validates
serpentinization_thickness against the cfg's mesh.resolution *
mesh.vt_size. A restart that changed either could stop serpentinization
with no message (a thickness under the old cell size reaches no cell)
or be refused although the grid it would really use was valid. Raised
in the PR #110 review (section 3).

When the sizes differ, restart() now warns and drops the restored grid;
the next phase change or remesh builds one at the cfg's size, as for a
checkpoint without a grid. Such a run does not continue the checkpointed
one bit-exactly; a restart with an unchanged cfg still does (vt-t0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The grid was rebuilt only at init and at each remesh, so in a run that
seldom remeshes it kept the slab geometry of its last build, and
serpentinization stopped following the slab.

The main loop now rebuilds it on each quality-check step, after the
surface processes. Markers of the indexed set are added or removed only
there and in a remesh, which rebuilds at its end, so the grid always
indexes every marker. This makes the removed-id set (mark_removed and the
live-position fallback of an unlabelled marker) dead, and it is deleted.
A host assert replaces it: subduction_phase_changes dies when the marker
count or the set's last id differs from the build's.

Results of option-3 runs with serpentinization change: serpentinite now
grows between remeshes.

Breaking: the checkpoint's grid layout changes (no removed ids, the
build's last id added); a checkpoint written before this cannot restart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The grid kept one std::vector per cell, so its memory, its rebuild cost
and its checkpoint grew with the cell count: a 3D 1000 x 1000 x 300 km
domain at 5 km resolution has about 19M cells, most of them empty
vector headers. The buckets are now cell offsets plus one id array,
filled from the per-marker labels by a counting sort, which keeps each
cell's ids ascending as before. The checkpoint stores only the labels and
derives the buckets from them, so its size follows the marker count.

Results are unchanged.

Breaking: the checkpoint's grid layout changes again; a checkpoint
written before this cannot restart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rpentinize

The grid reached the deepest marker, so in a deep domain most of its
cells lay below anything that can serpentinize: in a 660 km domain about
75% of them. Its bottom is now the 150 km depth cap plus the look-down
distance (serpentinization_thickness) plus one cell, and build_index()
leaves the markers below it out with label -1 rather than clamping them
into the bottom row. A converted wet-crust marker below the grid drops no
cell, and pass 2 skips a mantle marker below it. The cap is now one named
function, serpentinization_max_depth().

A domain shallower than the bottom keeps its grid, and its results are
unchanged. In a deeper one the rows now sit at fixed depths below the
surface instead of moving with the deepest marker, so results change: in
examples/subduction.cfg (160 km) serpentinite is 7% lower at 5 Myr.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fill_buckets() kept a second offset array, one int per cell, during each
rebuild, which doubled the per-cell cost the flat layout set out to cut.
It now places the ids from the last down, before each cell's running
start, so the offsets themselves end at the cell starts and each cell's
ids stay ascending. It also sizes the offsets itself, so the restart
reader no longer has to.

The comments now say what the reader does not bound (the cell count the
dims ask for), what the grid costs in all, and that a restart which
raises serpentinization_thickness keeps the old grid bottom until the
next rebuild.

Results are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he masses after

A slow step converted markers after update_mesh() had rebuilt the
lumped masses, so a second compute_mass() followed to keep a restart
from that step bit-exact. update_mesh() already rebuilds the masses at
its end; converting just before it lets that one rebuild see the new
composition. On a moving mesh this saves one compute_mass() every slow
step. A fixed mesh, which has no update_mesh(), still rebuilds them
after the conversions.

The rules now read each marker's position from the start of the step,
before the coordinate update; the pressure, a stress trace, does not
change under rotate_stress(). On a quality-check step they read the
grid of the previous quality check, which still indexes every marker,
and sediment deposited on that step converts one slow step later.
Results change for every phase_change_option but 0; option 0 is
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chaseshyu
chaseshyu force-pushed the feature/virtual-topology branch from 0df59fc to 4a3dda2 Compare October 27, 2026 17:22
chaseshyu added a commit that referenced this pull request Oct 27, 2026
The Unreleased section gains this branch's major user-visible changes:
the VirtualTopology grid and phase_change_option 3 under Added; the
renamed material keys and the unset default of the continental and
oceanic crust indices as Breaking entries, and the 2D surface plastic
strain that now decays in oceanic crust too, under Changed; and the
averaged-output fix, the phase-change mass fix and the temporary repair
of the bottom-snap fold under Fixed, each pointing to PR #110, with its
link definition. The smaller fixes stay in the pull request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lded mesh

A temporary repair of the bottom-snap fold. flatten_bottom() puts a
bottom node that sank below the bottom plane back onto it; when the node
sank far, a triangle above it turns inside out. MMG reorients that
triangle silently and returns a folded mesh, and the remesh stops with
exit 42. examples/subduction.cfg did so at 3.7 Myr on Vista (gcc 14).

When MMG's output is folded, optimize_mesh_2d() now lifts the interior
nodes of the input's inverted triangles, each by the least amount that
unfolds its triangle, so they move up with the bottom node, and runs MMG
once more. The fold test is check_mesh_tangle()'s own, now shared, so
only a remesh that would have stopped is repaired; every other run is
unchanged. In examples/subduction.cfg the repair lifted one node by
1.2 km, and the run reached 5 Myr; a restart from before the fold
reproduces it exactly.

The full fix, the bc.has_winkler_bottom_restore option, comes in a later
release; this repair then goes.

Not handled: 3D; an inverted triangle that only a boundary node can
unfold; a fold from a self-intersecting boundary; with remeshing option
13, a lifted node on a required interface edge bends that interface.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
chaseshyu added a commit that referenced this pull request Oct 27, 2026
The Unreleased section gains this branch's major user-visible changes:
the VirtualTopology grid and phase_change_option 3 under Added; the
renamed material keys and the unset default of the continental and
oceanic crust indices as Breaking entries, and the 2D surface plastic
strain that now decays in oceanic crust too, under Changed; and the
averaged-output fix, the phase-change mass fix and the temporary repair
of the bottom-snap fold under Fixed, each pointing to PR #110, with its
link definition. The smaller fixes stay in the pull request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chaseshyu
chaseshyu force-pushed the feature/virtual-topology branch from 4a3dda2 to bc20494 Compare October 27, 2026 17:35
@chaseshyu

Copy link
Copy Markdown
Member Author

Hi @tan2,

Thank you for the careful review.
It made the feature more correct and efficient in some way.

Here is what changed:

Run time. The subduction.cfg header now names the machine. The 5 Myr run takes 21 min 34 s on TACC Vista (NVIDIA Grace, g++ 14.2.0, 8 threads), and 28 min 56 s on an Apple M3 Max with 6 threads. The Mac was busy with other jobs during the run, so an idle one is faster.

Following are the revision regarding each comment:

1. Naming. "Crust" is now "wet crust" everywhere, and the parameter is now serpentinization_wet_crust_margin.

2. Stale grid. The grid is now rebuilt at every quality check. Markers change only at those steps, so the grid is never out of date for long. This let me delete the removed-id set and the unlabelled-marker fallback. Restarts are still bit-exact.

3. Restart. If vt_size or resolution changes, the restart now warns and rebuilds the grid.

4. Memory and speed.

  • The grid now uses one flat array instead of one vector per cell.
  • The grid now stops below the deepest mantle that can serpentinize.
  • phase_changes() now runs before update_mesh(). This removes the extra mass rebuild and makes each step about 3 % faster. I did not add the #pragma acc wait, because the two functions before it already end with one.

5. Input checks. mattype_continental_crust and mattype_oceanic_crust now default to −1. An unset index, or sediment that uses the oceanic-crust index, now stops the run at input. mattype_mantle is pushed only once. I also removed an old oceanic-crust exception in the surface plastic-strain decay, which was left over from the removed mid-ocean-ridge code.

Not in your review. The 5 Myr runs hit a known crash, where the bottom of the mesh folds. I added a temporary repair. It acts only when the mesh folds, so models that run today give the same results. The full fix, bc.has_winkler_bottom_restore, comes in a later release.

Not changed: the SurfaceTopo cache and the parallel build_index(). I measured both on Vista with examples/subduction.cfg, 1 Myr:

Part Share of run time
SurfaceTopo builds 0.08 %
Grid rebuilds 0.17 %
Whole phase-change rule 1.77 %

In a small 3D test model (about 0.39 M markers), both are about 0.1 %. The grid is rebuilt only once per quality-check interval. Its build time and the step time both grow with the number of markers, so the share should stay this small for large models. Each change would save less than 0.2 % of the run, and a cache could also change results. If a large 3D model shows otherwise, I will speed up the slowest part of the build.

The PR description has the full checks.

AI disclosure: I wrote this reply and the revision with help from Claude Code (Anthropic's Claude Opus 5.5). I reviewed and tested all the changes before I submitted them.

@tan2

tan2 commented Oct 29, 2026

Copy link
Copy Markdown
Member

Answers to your open questions

  1. Pressure. Please make it selectable. For example, add mat.phase_change_pressure_option, with 0 = element mean stress (the current default) and 1 = ref_pressure(param, -depth), using the surface-relative depth get_ZPT already computes. The cost is the same either way: one multiply or a PREM table lookup per marker. Options 1 and 101 already
    use ref_pressure (the older get_ZPT overload), and the phase-diagram boundaries are in lithostatic terms, so option 1 is the closer match to geoflac and the papers. Mean
    stress lets tectonic over- and underpressure shift the boundaries at the slab top.

  2. The constants. Yes, please make them input parameters: mat.serpentinization_max_depth (default 150e3, which also ends the grid) and mat.wet_crust_fraction_threshold
    (default 0.5). Both are labelled "untuned", and comment 1 shows how sensitive the result is to the grid.

  3. The static-mesh mass rebuild. No, keep it as it is. The masses depend on more than the composition. The density
    in MatProps::rho() changes with temperature (thermal expansion), so a rebuild every 10 steps isn't a no-op even when nothing converts. Skipping it would leave stale masses in the run.

chaseshyu and others added 3 commits October 30, 2026 20:09
The rules read the element's mean stress, so tectonic over- and
underpressure move the reaction boundaries. The phase diagrams they
come from are drawn for a lithostat, and geoflac reads one; the PR
reviewer asked for both, selectable.

mat.phase_change_pressure_option: 0 (the default) keeps the mean
stress; 1 reads ref_pressure() (control.ref_pressure_option) at the
depth below the local surface that the rules already use. get_ZPT()'s
surface-relative overload takes Param to choose. Any other value stops
at input with exit 11. Options 1 and 101 already read ref_pressure()
and ignore the key. The default runs are bit-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two untuned constants of the option-3 serpentinization were hard-coded:
no mantle below 150 km serpentinizes, and a grid cell is
wet-crust-dominant above half its markers. The PR reviewer asked for
keys, since the result is sensitive to both.

mat.serpentinization_max_depth (default 150e3, positive) is the cap and
also ends the grid; mat.wet_crust_fraction_threshold (default 0.5, in
[0, 1): the dominance test is a strict '>') sets dominance. Their
rationale moves from the code to --help. A restart that changes either
keeps the checkpoint's grid until the next rebuild, at most one quality
check later. The --help of option 3 also says "wet crust" where it
still said "crust". The default runs are bit-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Unreleased section gains this branch's major user-visible changes:
the VirtualTopology grid and phase_change_option 3 under Added; the
renamed material keys and the unset default of the continental and
oceanic crust indices as Breaking entries, and the 2D surface plastic
strain that now decays in oceanic crust too, under Changed; and the
averaged-output fix, the phase-change mass fix and the temporary repair
of the bottom-snap fold under Fixed, each pointing to PR #110, with its
link definition. The smaller fixes stay in the pull request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chaseshyu
chaseshyu force-pushed the feature/virtual-topology branch from bc20494 to 84bd8ae Compare October 30, 2026 12:13
@chaseshyu

Copy link
Copy Markdown
Member Author

Hi @tan2,

Thank you. I made both changes and added those three avariables:

mat.phase_change_pressure_option: 0 (default) = the element mean stress; 1 = ref_pressure() at the depth below the local surface.
mat.serpentinization_max_depth: (default 150e3; it also ends the grid)
mat.wet_crust_fraction_threshold: (default 0.5).

The static-mesh mass rebuild stays as it is. With the defaults, results are bit-identical. The PR description has the checks.

@tan2
tan2 requested review from sungho91 and tan2 October 30, 2026 13:21
@chaseshyu

Copy link
Copy Markdown
Member Author

Hi @tan2,

Because of your previous requested changes, this PR might need your approval to be merged.

@chaseshyu
chaseshyu dismissed tan2’s stale review October 30, 2026 23:04

This PR is ready to merge

@chaseshyu
chaseshyu merged commit d4ed64c into master Oct 30, 2026
27 checks passed
@chaseshyu
chaseshyu deleted the feature/virtual-topology branch October 30, 2026 23:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bugfixes Fix bugs enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants