diff --git a/cmake/thirdpartylibraries/SetupThirdPartyLibraries.cmake b/cmake/thirdpartylibraries/SetupThirdPartyLibraries.cmake index bf0df07..8d68ed4 100644 --- a/cmake/thirdpartylibraries/SetupThirdPartyLibraries.cmake +++ b/cmake/thirdpartylibraries/SetupThirdPartyLibraries.cmake @@ -8,6 +8,7 @@ set(_tpls snls exacmech mfem + axom caliper threads) @@ -122,6 +123,43 @@ if(SNLS_USE_RAJA_PORT_SUITE) endif() endif() # End SNLS_USE_RAJA_PORT_SUITE check +################################ +# Axom (optional) +################################ +# Axom installs a proper CMake package config (axom-config.cmake under +# ${AXOM_DIR}/lib/cmake/axom). find_package CONFIG mode picks it up +# automatically and imports the roll-up `axom` target plus per-component +# targets (axom::core, axom::spin, axom::slic, ...). We consume the +# roll-up target so whatever components Axom was built with come along +# transitively -- spin and slic for now, sidre when we add Conduit/HDF5. + +if (DEFINED AXOM_DIR) + set(axom_DIR ${AXOM_DIR}) + find_dependency(axom REQUIRED + NO_DEFAULT_PATH + PATHS ${AXOM_DIR}) + if (axom_FOUND) + # ---- Workaround for upstream Axom export bug ---- + # axom::slic's INTERFACE_LINK_LIBRARIES contains a bare 'lumberjack' + # entry inherited from BLT's internal target tracking when Axom is + # built with AXOM_ENABLE_LUMBERJACK=ON. Lumberjack is not in + # AXOM_COMPONENTS_ENABLED (it's a feature folded into slic, not a + # component built as its own library), so the reference is dangling. + # Without a stub here, every consumer of axom::slic gets -llumberjack + # on its link line and the linker fails to find it. + if (NOT TARGET lumberjack) + add_library(lumberjack INTERFACE IMPORTED) + endif() + option(ENABLE_AXOM "Enable Axom" ON) + message(STATUS "Found Axom: ${AXOM_DIR}") + else() + message(FATAL_ERROR "Unable to find Axom with given path ${AXOM_DIR}") + endif() +else() + message(STATUS "Axom support disabled") +endif() + + ################################ # Caliper ################################ diff --git a/experimental/mortar_pbc_proto/PROJECT_STATUS.md b/experimental/mortar_pbc_proto/PROJECT_STATUS.md new file mode 100644 index 0000000..f7407f7 --- /dev/null +++ b/experimental/mortar_pbc_proto/PROJECT_STATUS.md @@ -0,0 +1,531 @@ +# Mortar PBC Prototype: Status & Forward Plan + +> **For the comprehensive theory + practice + 3D-extension document, see +> `docs/MORTAR_PBC_ARCHITECTURE.md`.** That is the all-guiding reference; this +> file is the shorter pre-Phase-3 status snapshot. + +This document is the chat-restart summary for the mortar non-conforming +periodic-BC prototype. It captures (1) what's done and verified, +(2) the architectural decisions locked in along the way, (3) traps +encountered (so we don't re-encounter them), and (4) the forward +plan with open design questions. + +Last updated: end of Phase 2 (heterogeneous + checkerboard), 2D PASS on +np = 1, 2, 4, 8 in both layouts. + +--- + +## Goal + +Mortar-method non-conforming periodic boundary conditions for an RVE +solid mechanics problem. Built first as a pyMFEM prototype, then ported +to MFEM C++ for integration into ExaConstit (LLNL crystal-plasticity +code, MFEM/RAJA, updated-Lagrangian, partial-assembly GPU). + +Reference paper: Lopes, Ferreira, Andrade Pires (2021), CMAME 384, +113930. Copy at `/mnt/user-data/uploads/1-s2_0-S004578252100267X-main.pdf` +in the original conversation environment. + +--- + +## Status: what's done + +### Phase 1: distributed Krylov saddle-point on linear elasticity + +**1A: unpreconditioned distributed Krylov.** GMRES + BlockOperator +formulation. C represented as a Python Operator wrapping a scipy CSR; +the operator's `Mult`/`MultTranspose` do an Allgatherv of the input, +multiply by the (replicated) global CSR, and slice this rank's output. +K is consumed strictly via its operator interface — never gathered to +root, never converted to scipy CSR for the actual solve. + +**1B: block-Jacobi preconditioner.** Two diagonal blocks: +- `(0,0)` = `diag(K)^{-1}`, extracted via `Operator.AssembleDiagonal` + (works uniformly on PA, EA, FA, HypreParMatrix forms). +- `(1,1)` = `diag(C diag(K)^{-1} C^T)^{-1}`, computed without ever + forming the explicit C C^T product. The C operator exposes a + method `WeightedRowSqSum(weights, out)` that computes + `out[i] = sum_j C[i,j]^2 * weights[j]` for owned rows; this is a + collective (Allgatherv) call, parallel-safe. The element-wise-squared + C is cached at construction. + +Wrapped as Python `_DiagonalScaler` operators (`y[i] = inv_diag[i]*x[i]`) +and assembled via `mfem.BlockDiagonalPreconditioner`. Iteration counts +drop ~5x on the patch test. Verified PASS at machine precision +(`||du||_inf ~ 5e-15`) on np = 1, 2, 4, 8. + +### Phase 2: Newton on neo-Hookean + +**2.1 (homogeneous neo-Hookean).** Switched from BilinearForm K to +ParNonlinearForm. Newton outer loop wrapping the saddle-point solver +as the linear inner step. Verified Newton converges in 1 iteration on +the homogeneous patch (the linear deformation IS the exact solution and +the constraint reactions absorb all the imbalance — `u_tilde = 0` at +convergence). PASS np = 1–8. + +**2.2 (heterogeneous strip-split, 5× contrast).** Vertical strip: +elements with `centroid_x < L/2` get attribute 1 (matrix, E = 70e3); +others get attribute 2 (stiff, E = 350e3). `PWConstCoefficient(mu_vec)` +and `PWConstCoefficient(K_vec)` indexed by attribute, fed into +`NeoHookeanModel(mu_coef, K_coef)`. Quadratic Newton convergence +observed: + +``` +iter 0: 1.07e+06 +iter 1: 4.39e+05 +iter 2: 7.03e+04 +iter 3: 5.73e+03 +iter 4: 3.75e+01 +iter 5: 1.71e-03 (relative: 1.61e-09 — converged) +``` + +`||u_tilde||_inf = 8.04e-02` (non-trivial — the soft strip takes most +of the deformation). PASS np = 1–8. + +**2.4 (checkerboard, 5× contrast).** Same machinery, four-quadrant +diagonal-pair layout. Both periodic directions cross material +discontinuities; two intersecting internal interfaces. Closest 2D +analogue to the 3D RVE case. Driver: `examples/patch_test_2d_checkerboard.py`. + +(Step 2.3, "100× contrast stress test," skipped for now — the design +is solid enough that a contrast-bumping test isn't required before +moving to 3D. Easy to revisit if needed.) + +--- + +## Architectural decisions (locked) + +These are deliberate calls made during Phase 1/2; revisiting them needs +explicit justification, not casual drift. + +1. **UT (uniform traction) deferred but not blocked.** ConstraintAssembler + ABC + `stack_constraints` helper exists. Mortar PBC is the first + instantiation; UT can plug in later as another `ConstraintAssembler` + subclass. + +2. **K-block consumed as `mfem::Operator` only.** Never `tocsr()`, + never RAP, never gathered for the actual solve. This is the + GPU-portability requirement: PA-K must work without ever materializing + a CSR. Block-Jacobi prec uses only `AssembleDiagonal`. + +3. **Krylov runtime-selectable.** MINRES (default for symmetric K), + GMRES (non-symmetric K), BiCGStab. CG explicitly rejected (saddle-point + system is indefinite; CG diverges). + +4. **`SaddlePointSolver` is a mirror of `mfem::SchurConstrainedSolver` + but with operator-only K.** Current MFEM `constraints.hpp` + implementations (`SchurConstrainedHypreSolver`, `EliminationCGSolver`, + `PenaltyConstrainedSolver`) all require an assembled HypreParMatrix + K and use HypreBoomerAMG. Not GPU-friendly for PA-K. Our class + inherits the same external API (matches the ABC) but takes K as a + plain `Operator` and uses block-Jacobi prec. This is a candidate + upstream contribution to MFEM: a fourth `ConstrainedSolver` variant + for matrix-free K. + +5. **Solve-step API uses pre-assembled Newton residuals.** After a + sign-bug class encountered around the C^T λ contribution to the top + RHS, refactored to take `(r1_local, r2_local)` directly — the caller + assembles the FULL Newton residuals (including the `+ C^T λ_k` + contribution). Solver simply negates them. Eliminates sign-error + class entirely. + +6. **`SetIterativeMode(False)` on the inner Krylov solver.** Newton's + outer loop warm-starts at the OUTER level via `u_tilde` and `λ` — + those carry information across iterations correctly because they're + the actual unknowns. The inner linear solve is for the INCREMENTAL + update `(du, dλ)`; the previous step's `du` has no relevance to the + current step's, so inner warm-starting is a category error. Especially + important for CG (Lanczos breakdowns); also defensively correct for + GMRES. + +7. **Tribol deferred until working version exists.** We're not relying + on Tribol's mortar implementation; we built our own to learn the + mortar machinery + own the integration into ExaConstit's PA path. + +8. **SciPy direct solver quarantined to verification path only.** Lives + in `mortar_pbc/_verify_solver.py`. Not exported from package. Used + only as cross-check for the Krylov path. Production solve always + goes through `SaddlePointSolver`. + +9. **Newton convergence: relative force-balance + absolute constraint + + stagnation detection.** Three criteria: + - `||F_int + C^T λ||_2 < max(rtol * r0, atol)` (relative, with + absolute floor; `r0` = iter-0 residual norm). + - `||C u_tilde||_2 < atol_constraint` (absolute, constraint residual + is dimensionless). + - `||du||_2 < du_floor` (stagnation: linear solver can't improve + further; declare converged). + +10. **C++ build exposes all three MFEM ConstrainedSolver classes for + optional cross-check** (Schur/Elim/Penalty) — confirmed available + in pyMFEM build. + +--- + +## Critical lessons (the trap list) + +These came up the hard way. Worth keeping forefront. + +1. **Every collective must run on every rank.** No rank-0-only or + `n_lam_local > 0` guards around `C_op.Mult`, `CT_op.Mult`, + `WeightedRowSqSum`, `comm.allreduce`, `nlf.Mult`, `nlf.GetGradient`, + `BoundaryClassifier2D` construction, etc. Local guards only wrap + purely local computation (sentinel checks, negation loops over a + per-rank slice). + +2. **`BoundaryClassifier2D` collective construction must precede any + rank-0-only prints** to avoid asymmetric collective entry causing + deadlocks. + +3. **Element-wise `vec[i] = float(...)` writes are robust against + pyMFEM `GetDataArray` view-vs-copy ambiguity.** On some pyMFEM builds + `GetDataArray()` returns a view; on others it's a copy. Element-wise + assignment via `__setitem__` always works correctly. + +4. **`nlf.GetGradient` returns `mfem::Operator&` (base class).** The + dynamic type is normally `HypreParMatrix`, but pyMFEM exposes only + the base. For verification gather paths, attempt `mfem.Opr2HypreParMat` + downcast if exposed; else duck-type-check `hasattr(op, "MergeDiagAndOffd")`; + else gracefully skip the SciPy-direct verify path. Newton convergence + itself doesn't depend on this. + +5. **`ParNonlinearForm` handles essential DOFs internally.** Once + `nlf.SetEssentialTrueDofs(ess_tdof_list)` is called: + - `nlf.Mult(x, residual)` returns residual with essential DOFs + already zeroed. + - `nlf.GetGradient(x)` returns tangent with essential rows/cols + already eliminated. + Calling our own `apply_dirichlet_to_distributed_K` on the result + would corrupt K (double-elimination). Only the LINEAR-elastic + driver (`patch_test_2d.py`) uses the manual path; the nonlinear + drivers MUST NOT. + +6. **The Newton residual MUST include the `C^T λ_k` contribution.** + `||F_int||_2` alone stagnates at the natural force scale of the + problem (~2.7e5 for our case, same as iter 0) regardless of how + converged the actual equilibrium is. The quantity that goes to + zero at equilibrium is `||F_int + C^T λ||_2`. Iter 0 has λ=0 so + the term is zero; iter 1+ must add `C^T λ_k` before the convergence + check AND pass the augmented residual to `solve_step`. + +7. **Verification gather block must mirror the in-loop residual + construction.** After Newton converges, the post-loop verify path + recomputes `nlf.Mult(x, final_residual)` (giving F_int alone) and + gathers it. Without re-adding `C^T λ`, the gathered residual is + the natural-scale F_int (~1e5) rather than the converged residual + (~1e-9 relative). Easy bug to miss because Newton trace looked + right; only the verification panel showed the wrong number. + +8. **Absolute Newton tolerance ignores problem scale.** For Lamé + modulus O(1e4) and natural force O(1e5), an `atol = 1e-10` is + physically meaningless — orders of magnitude below floating-point + noise floor at this problem scale. Use relative drop from `r0` + with absolute floor as safety net for trivially-tiny problems. + +9. **Krylov stagnation when the linear solve has nothing to do.** + When Newton has already converged on a previous iteration but the + outer loop hasn't recognized it yet, the next Krylov call sees a + tiny RHS, exits with 0 iterations, returns du=0. Without + stagnation detection in the Newton outer loop, this loops to + max_iter pretending Newton failed. Always include `||du|| < floor` + as a convergence path. + +10. **Pointer/lifetime conventions in pyMFEM.** `BlockDiagonalPreconditioner` + does NOT own its diagonal blocks. Python GC will collect them + mid-Krylov-solve unless explicit references are kept alive in + a list outside the function scope. `SaddlePointSolver._build_block_jacobi_prec` + returns a `keepalive` list specifically for this; the caller stashes + it on `self._last_prec_refs`. + +--- + +## Warm-start commentary (for future multi-load-step driver) + +ExaConstit handles BC changes between time steps via `SystemDriver::SolveInit` +(`src/system_driver.cpp:441-478`). The motivation, captured in +ExaConstit issue #8 (github.com/llnl/ExaConstit/issues/8): + +The constrained DOFs (the essential boundary) are NOT being warm-started +in any approximate sense — they're set EXACTLY to their prescribed +values for step `n+1`. The issue is the **unconstrained DOFs**: at the +start of step `n+1`, their previous-step values `v_u^n` are no longer +in equilibrium with the new boundary values `v_c^{n+1}`, and starting +Newton from `(v_u^n, v_c^{n+1})` injects a large artificial residual at +the first Newton iterate. For severe BC changes, this can put Newton's +first iterate into a bad region (e.g. `J < 0` for hyperelastic). + +The SolveInit projection works as follows: + +``` +Step 1 (warm-start projection, before Newton): + 1a. K_n := tangent stiffness from previous converged state. + 1b. ΔR_u := -K_{uc} (v_c^{n+1} - v_c^{n}) + The change in residual at unconstrained DOFs caused by the + change in CONSTRAINED-DOF values from step n to n+1. + K_{uc} is the sub-matrix coupling unconstrained rows to + constrained columns. + 1c. Solve K_n Δv^{n+1} = -(R^n + ΔR_u) for Δv. + R^n is the previous step's residual (zero at converged + state; non-zero if step n didn't fully converge — + captured here). + 1d. Initial guess for Newton: v^{n+1}_initial = v^n + Δv^{n+1}. + The unconstrained DOFs now have a sensible starting value + that reflects the BC change linearly through the + previous-step tangent. + +Step 2 (Newton solve, as normal): + 2a. Apply v_c^{n+1} EXACTLY to the constrained DOFs. + 2b. Run Newton from v^{n+1}_initial. +``` + +ExaConstit's primal field is **velocity**, and the prescribed velocity +gradient changes every load step — so without SolveInit, every step +starts Newton from a state that's non-equilibrium at the unconstrained +DOFs because the constrained values just jumped. + +**For our PBC mortar formulation:** the unknown is `u_tilde` (the +periodic fluctuation), and `u_tilde`'s essential BCs are the corner +Dirichlets fixed at zero — these don't change between load steps. +What changes is `u_lin = (F_macro - I) Y`, added to `u_tilde` to form +the total state. The SolveInit equivalent for our setup would be: + +``` +Δu_lin := u_lin^{n+1} - u_lin^{n} +ΔR_unconstr := -K_{uc} Δu_lin (NOT -K_{uc}(v_c^{n+1} - v_c^{n}); + our "constrained values" of u_tilde + are zero at corners and don't change. + But the LINEAR PART u_lin DOES change, + and that's the analogue here.) +Solve K Δu_tilde = -(R^n + ΔR_unconstr) +u_tilde^{n+1}_initial = u_tilde^n + Δu_tilde +``` + +So we DO need a SolveInit equivalent for multi-load-step F_macro +ramping — it's just expressed in terms of `u_lin` change rather than +constrained-DOF value change. This wasn't relevant in single-step +testing (Phases 1–2) because we only had one load step: cold-start +`u_tilde = 0` and let Newton converge. For Phase 6+ multi-step +loading, this projection becomes mandatory. + +**Where this becomes additionally relevant beyond F_macro ramping:** +- Velocity-based primal formulation (rate-dependent crystal plasticity) + follows ExaConstit's setup directly — `v_c` is the prescribed + velocity at each step and SolveInit applies as written. +- Prescribed displacements on boundaries beyond the corner Dirichlets + (e.g. displacement-controlled loading on an entire edge) — same + thing, with `u_c^{n+1} - u_c^n` driving the projection. + +Both are post-port concerns. Recommendation: when we get to Phase 6 +multi-step driver, port ExaConstit's SolveInit pattern (it's a single +linear solve, cheap), generalized to also handle the `Δu_lin` case. + +--- + +## Code layout + +``` +mortar_pbc_proto/ +├── mortar_pbc/ # the package +│ ├── __init__.py # exports public API +│ ├── types_2d.py # EdgeNodes2D, CornerInfo dataclasses +│ ├── boundary_2d.py # BoundaryClassifier2D (with DofToVDof fix) +│ ├── mortar_2d.py # N_line2, M_line2_dual, MortarBlock2D, +│ │ MortarAssembler2D +│ ├── constraint_builder.py # ConstraintBuilder2D — scipy CSR build +│ ├── constraint_assembler.py # ABC + MortarPbcConstraintAssembler + +│ │ stack_constraints helper +│ ├── saddle_point.py # SaddlePointSolver (Krylov + block-Jacobi +│ │ prec); make_constraint_operators +│ │ factory; _DiagonalScaler helper +│ └── _verify_solver.py # SciPyDirectSolver (quarantined) +├── examples/ +│ ├── patch_test_2d.py # Phase 1B regression baseline +│ │ (linear elastic, single solve) +│ ├── patch_test_2d_heterogeneous.py # Step 2.2: strip-split, 5x +│ └── patch_test_2d_checkerboard.py # Step 2.4: 4-quadrant, 5x +└── tests/ + └── test_mortar_2d_unit.py # 5 unit tests: + dual basis bi-orthogonality, + partition of unity, + conforming pair lumping, + non-conforming linear-field + reproduction, + ConstraintAssembler ABC + + stack_constraints +``` + +--- + +## Forward plan + +### Phase 3: 3D mortar (next major work) + +**Wirebasket structure.** 3D RVE has: +- 8 corners — must be Dirichlet-pinned (3 components each → 24 TDOFs). +- 12 edge wirebaskets — periodic in their direction; 4 wirebaskets per + spatial direction, each pairing 4 edges. +- 6 face pairs — periodic; 3 pairs (one per spatial direction). + +Each face pair has the same kind of mortar coupling we built for 2D +edges, but on 2D surface integrals over face geometry. Each edge +wirebasket couples 4 line edges (not 2), and the corner constraint +involves 8 corners, not 4. + +**Polygon clipping for 2D segmentation pieces.** When the non-mortar +face's elements aren't aligned with the mortar face's, each pair of +overlapping element faces must be intersected to form a polygon, then +quadrature is built on this polygon. Robust polygon clipping in 3D is +non-trivial; Sutherland-Hodgman or similar. + +**Triangular vs quadrilateral non-mortar elements.** For our +extruded-quad-on-quad ExaConstit meshes, both faces are quads. But +we should design for general — the Lopes paper covers triangular +non-mortar elements too (Appendix C). + +**Dual basis modifications.** Lopes Eq. C.1 gives the line-2 (1D) +dual basis. For 3D faces, we need the 2D analogue — Wohlmuth's +biorthogonal basis on quad and triangle reference elements. The +corner+edge wirebasket modifications (Wohlmuth) are subtle: dual +basis functions near corners need correction terms to maintain +biorthogonality across the geometric singularities. + +**Open Phase 3 design questions:** + +1. **Constraint storage layout.** In 2D, C is replicated on every + rank (28x162, only 92 nnz; cheap). In 3D with O(10K) face pairs and + O(100) wirebasket constraints per direction, replicated C is no + longer free. Options: + (a) Distribute C — owned-row partitioning matching face-element + distribution. Mult/MultTranspose become more complex. + (b) Replicate per constraint group (faces, edges, corners + separately), block-diagonalized. + (c) Stay replicated and just accept the memory cost (probably + fine through 100K elements). + + Recommend starting with (c) and migrating to (a) only if memory + becomes a real bottleneck. + +2. **Reference vs spatial configuration for mortar integration.** In + updated Lagrangian, the reference mesh and spatial mesh differ. + Mortar integrals can be evaluated on either. Lopes uses reference + (the formulation is reference-Lagrangian). ExaConstit is updated + Lagrangian — at each load step, reference resets. This matches the + reference-mortar convention naturally; just rebuild C at each load + step's reset. + +3. **Dual basis integration order.** The Wohlmuth-modified dual basis + has discontinuities along corner/edge boundaries. Quadrature must + be subdivided at these discontinuities. Tricky; need to think + through the subdivision logic before coding. + +### Phase 4: MPI for 3D + +Same template as 2D — operators wrap distributed CSRs; collective +correctness baked into every Mult. Bigger Allgatherv volumes; might +push us into "distributed C" sooner than just memory-driven. + +### Phase 5: C++ port to ExaConstit + +**Class design.** `MortarPbcSchurSolver` (or similar) inherits from +`mfem::ConstrainedSolver`, mirroring the existing +`SchurConstrainedHypreSolver` API but with operator-only K and +block-Jacobi prec. The ConstraintAssembler ABC pattern carries over +to C++ as a virtual interface; mortar-PBC is one implementation, +UT will be another, and Tribol-based contact would be a third. + +**Possible upstream MFEM contribution.** MFEM's existing +`mfem::ConstrainedSolver` family doesn't have a matrix-free / PA-friendly +variant. Our `MortarPbcSchurSolver` IS that variant. After ExaConstit +integration is solid, propose upstream as a new ConstrainedSolver +subclass. Reference: `mfem/linalg/constraints.hpp` for the existing +ABC and three implementations. + +**Hooks to existing ExaConstit infrastructure:** +- `SystemDriver::SolveInit` — warm-start path; needs extension to handle + PBC if/when we add prescribed displacements beyond corner Dirichlets. +- `BCManager` — currently handles essential BCs by attribute; PBC is + a different beast (constraint-based, not essential-BC-based). May + need a new manager class or a generalized `ConstraintManager`. +- `mech_operator` — the ParNonlinearForm equivalent. Wires into our + saddle-point solver as the K-operator source. + +**What's NOT going to MFEM upstream.** The mortar assembly itself +(`MortarAssembler2D` and friends). That's domain-specific to our PBC +setup; lives in ExaConstit. Upstream contribution is the +`ConstrainedSolver` subclass only. + +### Phase 6+: extensions (post-port) + +- **Multi-load-step driver** with proper warm-start handling. +- **Velocity-based primal formulation** (rate-dependent constitutive + models need this; SolveInit-style projection at each step). +- **Tribol integration** as a third `ConstraintAssembler` for contact + problems. +- **Uniform traction (UT) BCs** as a second `ConstraintAssembler` — + the ABC was designed with UT in mind from the start. + +--- + +## Open questions before resuming + +1. **Should we run the 100× contrast stress test before moving to 3D?** + (Step 2.3, deferred.) Cheap to do; would add confidence that + Newton + block-Jacobi prec hold up under aggressive contrast. + +2. **Phase 3 Q1: distributed vs replicated C in 3D?** Recommendation + above is "start replicated, migrate if needed." Confirm before + starting. + +3. **Phase 3 Q2: which 3D mesh source?** pyMFEM has `MakeCartesian3D` + for the prototype. For meaningful non-conforming tests, we need + meshes whose face pairs really don't match — need to either build + them by hand or extend `build_nonconforming_square` to a + `build_nonconforming_cube` analog. + +4. **Polygon clipping library or hand-roll?** Sutherland-Hodgman is + simple enough to hand-roll for convex-on-convex (which is our case + for quad-on-quad face pairs). shapely has it but is a heavy + dependency. Recommend hand-rolling. + +--- + +## Run reference (validated as of last session) + +All on np = 1, 2, 4, 8 — PASS in every case. + +``` +python examples/patch_test_2d.py # Phase 1B regression +python examples/patch_test_2d_heterogeneous.py # Step 2.2 strip-split +python examples/patch_test_2d_checkerboard.py # Step 2.4 checkerboard + +python tests/test_mortar_2d_unit.py # 5 unit tests +``` + +--- + +## Environment + +- pyMFEM commit 7e99b925, MFEM 4.9, conda-forge openmpi +- Python 3.9, conda env `mortar-pbc` +- macOS, `MACOSX_DEPLOYMENT_TARGET=11.0` +- Build: `pip install ./ -C"with-parallel=Yes" --verbose` (from PyMFEM + source) + +pyMFEM exposed (verified in use): +- `PyOperatorBase`, `BlockOperator`, `BlockDiagonalPreconditioner` +- `MINRESSolver`, `GMRESSolver`, `BiCGSTABSolver` (no CG — see note) +- `ParNonlinearForm`, `HyperelasticNLFIntegrator`, + `NeoHookeanModel(mu_coef, K_coef)` +- `SchurConstrainedHypreSolver`, `EliminationCGSolver`, + `PenaltyConstrainedSolver` (all three available; not currently used + except as design reference) +- `ToScipyCSR`, `ToHypreParCSR`, `Opr2HypreParMat` (the last is the + Operator → HypreParMatrix downcast helper) +- `PWConstCoefficient(mfem.Vector)` for per-attribute material +- `intArray`, `Array` various utility types + +--- + +End of project status. When resuming, start by re-reading this file +and verifying the runs above still pass. Pick from "Open questions" +or proceed directly to Phase 3 planning. diff --git a/experimental/mortar_pbc_proto/README.md b/experimental/mortar_pbc_proto/README.md new file mode 100644 index 0000000..bafc6ae --- /dev/null +++ b/experimental/mortar_pbc_proto/README.md @@ -0,0 +1,289 @@ +# Mortar PBC prototype for ExaConstit + +> **Looking for the full theory + practice + 3D-extension reference?** See +> [`docs/MORTAR_PBC_ARCHITECTURE.md`](docs/MORTAR_PBC_ARCHITECTURE.md). This +> README is the quickstart; the architecture doc is the comprehensive +> all-guiding reference (vocabulary, math, the trap list, the 3D Phase-3 plan, +> the C++ port pathway, references). + +Python / pyMFEM prototype of dual-basis mortar periodic boundary +conditions for non-conforming RVE meshes, following Lopes, Ferreira & +Andrade Pires, *CMAME* **384** (2021) 113930. Precursor to an eventual +MFEM C++ implementation that will land in ExaConstit. + +Phase 1 scope: 2D rectangular RVEs, H1 vector-linear elements, MPI-aware +saddle-point Newton step solved via gather-to-root + `scipy.sparse.linalg.spsolve`. + +--- + +## 1. Recommended environment + +The Python-only unit tests need just NumPy + SciPy. The driver +(`examples/patch_test_2d.py`) needs pyMFEM with parallel build +(MPI + HYPRE) plus mpi4py. Targeted versions: + +| Component | Version / commit | +|-----------|-----------------------------------------------------------------| +| Python | 3.10 – 3.12 (pyMFEM supports 3.8+; 3.10+ for the modern type-hint syntax used here) | +| MFEM | 4.9 (the version pyMFEM commit `7e99b925` targets) | +| pyMFEM | commit `7e99b925cfcbec002c9e21230b3c561cb19436a6` (develop, MFEM 4.9 build fixes; PR #300) | +| MPI | OpenMPI ≥ 4.0 or MPICH ≥ 3.3 (must match what mpi4py was built against) | +| SWIG | ≥ 4.2.1 (pyMFEM build requirement) | +| NumPy | ≥ 1.22 | +| SciPy | ≥ 1.10 | +| mpi4py | ≥ 3.1 | + +A clean conda env is the fastest path; if you prefer venv, do that. + +```bash +# --- Conda variant --- +conda create -n mortar-pbc python=3.11 numpy scipy mpi4py openmpi cmake swig -c conda-forge +conda activate mortar-pbc +# --- venv variant (system MPI + SWIG must already be present) --- +python -m venv ~/.venvs/mortar-pbc +source ~/.venvs/mortar-pbc/bin/activate +pip install numpy scipy mpi4py +``` + +Sanity-check `mpi4py` and the matching MPI launcher are in agreement +before you do anything else: + +```bash +python -c "from mpi4py import MPI; print(MPI.Get_library_version())" +mpirun --version +``` + +--- + +## 2. Install pyMFEM (parallel build, pinned to the MFEM-4.9 commit) + +```bash +# Pick a workspace +cd ~/src # or wherever you keep checkouts + +# Clone PyMFEM +git clone https://github.com/mfem/PyMFEM.git +cd PyMFEM +git checkout 7e99b925cfcbec002c9e21230b3c561cb19436a6 + +# Build with MPI. This downloads + builds MFEM, METIS, and HYPRE +# locally; takes 10-20 min on a recent laptop. +pip install ./ -C"with-parallel=Yes" --verbose +``` + +Notes on the pyMFEM build: + +- The `--verbose` flag is recommended on a first build so you can see + where things go if something fails. +- If you want to point at an existing MFEM/HYPRE/METIS installation + rather than letting pyMFEM download and build them, see + [PyMFEM/INSTALL.md](https://github.com/mfem/PyMFEM/blob/mortar/INSTALL.md) + for the `--mfem-prefix` / `--mfem-source` / `--hypre-prefix` flags. + This is the path you'll likely want on a cluster where MFEM is + already module-loaded. +- On macOS with Apple Silicon you may need to set + `CFLAGS="-Wno-incompatible-function-pointer-types"` in the env before + the pip install if SWIG-generated code triggers the strict default. + +Verify pyMFEM came out parallel: + +```bash +python -c "import mfem.par; print('pyMFEM parallel OK,', mfem.par.__file__)" +python -c "from mfem.common.parcsr_extra import ToScipyCSR; print('ToScipyCSR OK')" +``` + +If the second command works, the gather-to-root path in +`hypre_to_scipy_csr` will work. + +--- + +## 3. Install the prototype + +The prototype is plain Python — no compilation step. Two install paths: + +### 3a. Editable install (recommended for development) + +From the prototype's root directory: + +```bash +cd /path/to/mortar_pbc_proto +pip install -e . +``` + +(There's no `setup.py` shipped — see step 3b for the no-install path +that's actually being used right now. Drop in a minimal `pyproject.toml` +later if you want.) + +### 3b. PYTHONPATH (no install at all) + +Easiest path right now. From the prototype's root: + +```bash +cd /path/to/mortar_pbc_proto +export PYTHONPATH="$PWD:$PYTHONPATH" +``` + +Then `import mortar_pbc` works. The unit tests and the driver script +already do `sys.path.insert(...)` so they don't actually need this; only +ad-hoc `python -c "import mortar_pbc"` benefits. + +--- + +## 4. Test the prototype + +### 4a. Unit tests (no pyMFEM needed) + +Five tests covering: dual-basis bi-orthogonality, partition of unity, +conforming-pair lumping, non-conforming-pair linear-field reproduction, +and the `ConstraintAssembler` ABC + `stack_constraints` machinery. +Pure NumPy — runs in any Python env. + +```bash +cd /path/to/mortar_pbc_proto +python tests/test_mortar_2d_unit.py +``` + +Expected output: + +``` +Running mortar 2D unit tests +------------------------------------------------------------ +Test 1: dual basis bi-orthogonality + PASS dual basis bi-orthogonality (max err 1.39e-17) +Test 2: shape function partition of unity + PASS N partition of unity (max err 0.00e+00) +Test 3: conforming pair recovers lumped mass + ... + PASS conforming pair recovers lumped mass +Test 4: non-conforming pair row-sum consistency + ... + PASS non-conforming pair reproduces constant + linear fields +Test 5: ConstraintAssembler ABC + stack_constraints + ... + PASS ConstraintAssembler ABC + stack_constraints +------------------------------------------------------------ +All unit tests passed. +``` + +If anything in that block fails, **stop** and don't move on to step 4b +— the unit tests cover the math; if they don't pass on your box, +nothing downstream will. + +### 4b. Patch test, np = 1 (homogeneous RVE recovers `u_tilde = 0`) + +```bash +cd /path/to/mortar_pbc_proto +mpirun -n 1 python examples/patch_test_2d.py +``` + +Or equivalently, since np=1 means no actual MPI launch is needed: + +```bash +python examples/patch_test_2d.py +``` + +Look for these lines at the bottom: + +``` + ||C u_tilde||_2 = + ||u_tilde||_inf = + ||du||_inf = + PASS +``` + +The patch test imposes the macroscopic deformation gradient +`F = [[1.5, 0.5], [0.5, 1.0]]` on a homogeneous square RVE. Theory +says the fluctuation `u_tilde` should be zero everywhere — this is +exactly the discrete patch-test criterion (Lopes §5.1.1). If it +**fails** on np = 1, the issue is one of: + +- The boundary attribute layout (1=bottom, 2=left, 3=top, 4=right) was + set wrong by the mesh builder — uncomment the diagnostic in + `BoundaryClassifier2D.summary()` to inspect. +- The corner-Dirichlet elimination didn't reach all four corners — check + `corner_dirichlet_gtdofs` output. +- The mortar coupling has a bug that the unit tests didn't catch — + unlikely given the unit tests pass, but possible. + +### 4c. Patch test, np = 2 (exercises the gather-to-root path) + +```bash +mpirun -n 2 python examples/patch_test_2d.py +``` + +Or `mpirun -n 4`, `mpirun -n 8` for a stronger MPI test. Same PASS +criteria. If np=1 passes but np>1 fails, suspects in order: + +1. **`HypreParMatrix.GetRowPartArray()` returning unexpected shape.** + Print `np.asarray(K_hyp.GetRowPartArray())` from inside + `hypre_to_scipy_csr` to see what your HYPRE build produces. My code + handles both `[first, last_excl]` (assumed-partition) and the full + `nranks+1` form. +2. **`ToScipyCSR` not finding `MergeDiagAndOffd`.** Check + `python -c "from mfem.par import HypreParMatrix; m = HypreParMatrix; print(hasattr(m, 'MergeDiagAndOffd'))"`. +3. **MPI launcher / mpi4py mismatch.** If `mpirun -n 2` runs two + independent serial copies (each printing rank=0), the launcher and + mpi4py are linked against different MPI implementations. Easy + diagnostic: run `mpirun -n 2 python -c "from mpi4py import MPI; print(MPI.COMM_WORLD.Get_rank(), MPI.COMM_WORLD.Get_size())"` — both ranks should + print, with sizes = 2. +4. **`apply_linear_part` returning a different size on each rank than + `fes.GetTrueVSize()`.** Add `assert u_lin_local.size == fes.GetTrueVSize()` + right after the call. + +--- + +## 5. What's there + +``` +mortar_pbc_proto/ +├── README.md ← this file +├── mortar_pbc/ +│ ├── __init__.py ← package surface, lazy MFEM imports +│ ├── types_2d.py ← EdgeNodes2D, CornerInfo dataclasses +│ ├── mortar_2d.py ← dual basis + A^m, D^nm assembly +│ ├── constraint_builder.py ← global C from mortar blocks +│ ├── constraint_assembler.py ← ABC + stack helper (UT extension hook) +│ ├── saddle_point.py ← [[K, C^T], [C, 0]] direct solve +│ └── boundary_2d.py ← MFEM-dependent boundary classifier +├── examples/ +│ └── patch_test_2d.py ← driver + gather/scatter helpers +└── tests/ + └── test_mortar_2d_unit.py ← 5 unit tests (pyMFEM-free) +``` + +Every module has a What/Why/References docstring tying back to the +specific equations and figures of Lopes et al. (2021). Inline comments +flag the parts that are non-obvious to a reader familiar with +ExaConstit but new to mortar methods (corner-mod intentionally breaking +bi-orthogonality, dual-basis asymmetry, etc.). + +The `K`-block of the saddle-point system is consumed *as an interface* +in the design — the prototype materializes it to scipy CSR only because +`spsolve` needs that. ExaConstit's actual K (PA / EA / FA, whatever +the run is configured for) plugs in at this seam in the C++ port; see +the docstring of `mortar_pbc.saddle_point.SaddlePointSolver` for the +extension point. + +--- + +## 6. Where the next round of work is going + +In rough priority order: + +1. Phase 2: heterogeneous RVE + neo-Hookean + Newton iteration coupled + to `mfem.ParNonlinearForm.GetGradient()` (the C++ ExaConstit-shaped + way of doing it). This is the first real test that the K-as- + interface design holds up. +2. Serial 3D: wirebaskets (4 edges per direction collapsing to one + mortar edge with 3 non-mortar) + quadratic non-mortar treatment per + §C of Lopes et al. +3. MPI 3D. +4. Investigate Tribol's API for D^nm / A^m exposure as standalone + artifacts (deferred until 1–3 are solid). +5. C++ port into ExaConstit. + +Uniform traction (UT) is intentionally deferred until ExaConstit grows +a traction BC. The `ConstraintAssembler` ABC is the extension point — +adding UT later means writing one new `UniformTractionConstraintAssembler` +subclass and stacking it via `stack_constraints`. No other code +changes. diff --git a/experimental/mortar_pbc_proto/docs/MORTAR_PBC_ARCHITECTURE.md b/experimental/mortar_pbc_proto/docs/MORTAR_PBC_ARCHITECTURE.md new file mode 100644 index 0000000..2ef6cc2 --- /dev/null +++ b/experimental/mortar_pbc_proto/docs/MORTAR_PBC_ARCHITECTURE.md @@ -0,0 +1,4983 @@ +# Mortar Periodic Boundary Conditions for Computational Homogenization +## Theory, Practice, and a Roadmap from 2D to 3D, ExaConstit-Bound + +> **Living architecture document.** Read this once before touching the code; refer +> back to it when designing new pieces. Anyone joining the project — whether they +> already know FEM but not mortar methods, or vice versa — should leave this doc +> understanding *why* every architectural choice was made and *how* the pieces +> interlock to form a single homogenization driver. + +--- + +## Document scope and audience + +This document is the all-guiding reference for the mortar non-conforming periodic +boundary conditions (PBC) prototype, developed in pyMFEM as a precursor to +production C++ integration into ExaConstit (LLNL crystal-plasticity FE code, +MFEM/RAJA-based, partial-assembly / GPU). It captures: + +1. **The math**: enough computational mechanics and mortar-method theory that a + reader with a normal FEM background but no specialised PBC / mortar exposure + can follow every algorithmic decision. +2. **The current code**: what each module does and why; how the saddle-point, + constraint-builder, and warm-start pieces fit together. +3. **The hard-won lessons**: the bugs we hit, the half-formulations that nearly + worked, and the diagnostics that finally caught the problem. Future-Claude (or + future-anyone) should not re-discover these. +4. **The 3D extension plan**: the hierarchical wirebasket structure, the dual-basis + modifications, the staging, the open design questions. Treat this section as + the working contract for what Phase 3 means and how it stages into ExaConstit. + +The total length is intentional. A short doc would force readers back to the +2021 Lopes paper and our six prior session transcripts; this doc is a single +self-contained source of truth. + +> If you are reading this to start work, the recommended first pass is: +> §0 (vocabulary), §1 (high-level mental model), §2 (Method C vs D), §10 (status +> at this checkpoint), §11 (Phase 3 plan). The remaining sections are reference. + +--- + +## Table of Contents + +- §0. Vocabulary and notation +- §1. The big picture: what computational homogenization needs from PBC +- §2. Two formulations: Method C vs Method D, and why we use D +- §3. The mortar method — variational form, discrete construction, algorithm +- §4. The dual basis: derivation, simplex unification, and explicit formulas + - §4.0 Derivation from the bi-orthogonality requirement + - §4.1 Simplex unification: line-2, tri-3, tet-4 (M_i = (d+2) N_i − 1) + - §4.2 Line-2 (1D simplex) + - §4.3 Quad-4 (2D hypercube tensor product) + - §4.4 Tri-3 (2D simplex; tet-mesh face element) + - §4.5 Tet-4 (3D simplex; for volume mortar) + - §4.6 Hypercubes vs simplices + - §4.7 Why bi-orthogonal: condition number and Schur complement + - §4.8 Higher-order: the line-3 dual basis (1D, p = 2) + - §4.9 The bi-orthogonality obstruction at p ≥ 2 on simplices and serendipity (with general predictive criterion) + - §4.10 The Popp-Wohlmuth-Gee-Wall basis-transformation procedure + - §4.11 The lower-order projection (LOR) fallback + - §4.12 Recommendation for ExaConstit higher-order PBC +- §5. Hierarchical crosspoint structure and the Wohlmuth modification + - §5.1 The 2D problem and the line-2 modification + - §5.2 The triangle (tri-3) modification (3D face mortar on tet meshes) + - §5.3 The quad-4 modification (3D face mortar on hex meshes) + - §5.4 The 3D wirebasket hierarchy + - §5.5 Hex meshes vs tet meshes: same hierarchy, different elements + - §5.6 Why this matters for correctness +- §6. The saddle-point system and how we solve it +- §7. Warm-start theory: from ExaConstit's `SolveInit` to multi-step F ramping + - §7.4 Derivation of the projection equation (eq. 7.4) +- §8. Diagnostics: volume-averaged F as the consistency check + - §8.1 Hill-Mandel theorem with explicit divergence-theorem derivation +- §9. Visualisation and the total-Lagrangian discipline +- §10. Status at the Phase-2 ↔ Phase-3 boundary +- §11. Extending to 3D: the wirebasket framework + - §11.1 The hierarchy and what changes from 2D + - §11.2 Hex track: hex-8 volumes with quad-4 face mortar + - §11.3 Tet track: tet-4 volumes with tri-3 face mortar + - §11.4 Mixed hex-tet meshes + - §11.5 The 3D edge mortar + - §11.6 The face mortar geometric-matching algorithm + - §11.7 The 3D mesh + boundary classifier + - §11.8 The phasing plan for Phase 3 + - §11.9 Open Phase-3 design questions +- §12. Hard-won lessons (the trap list) +- §13. C++ port pathway into ExaConstit +- §14. Open questions and forward plan +- §15. References + +--- + +# §0. Vocabulary and notation + +This section is for readers with a regular FEM background who have not worked +on mortar methods or RVE homogenization before. Skim it; come back when an +unfamiliar term appears. + +| Symbol / term | Meaning | +|---|---| +| **RVE** | Representative Volume Element. The microscale domain Ω over which we solve a boundary-value problem and from which we read back homogenized stress / tangent. For us, Ω is a square (2D) or cube (3D); call its side length L and its volume V. | +| **F**, **F_macro** | The (prescribed) macroscopic deformation gradient. A 2×2 (resp. 3×3) tensor that drives the homogenization. | +| **u(X)** | Total displacement field on the RVE. Reference coordinates X. | +| **u_lin(X)** | The affine part: u_lin = (F − I) X. By construction this gives ∇u_lin = F − I, a constant field that reproduces F exactly. | +| **ũ(X), u_tilde** | The fluctuation: ũ = u − u_lin. Required to be Ω-periodic so that ⟨F⟩_Ω = F_macro by the average theorem. | +| **nonmortar / mortar** *(or **−** / **+**, equivalently B / A)* | The two sides of a mortar coupling. The Lagrange-multiplier rows live on the **nonmortar** ("−", "B") side; the **mortar** ("+", "A") side provides the values that feed the constraint. Naming follows the Wohlmuth-mortar literature and the `D^{nm}` / `A^m` matrix names: the "nm" superscript on D refers to the nonmortar-side mass; the "m" superscript on A refers to the mortar-side trace. The dual basis lives on the nonmortar side. **Pre-existing convention note:** the Python prototype's docstrings (e.g. `mortar_pbc/mortar_2d.py`, citing the Lopes 2021 paper) use the opposite "+"/"−" mapping ("+" = nonmortar, "−" = mortar). The mapping to "nonmortar"/"mortar" is unambiguous; the +/− symbols are a recurring source of cross-paper notational disagreement. | +| **C** | The constraint matrix: rows index Lagrange multipliers (one per nonmortar-side periodic DOF, per spatial component); columns index displacement TDOFs. C·u = 0 is the discrete periodicity condition. | +| **λ** | Lagrange multipliers, one per row of C. Physically: the periodic-traction reactions on the nonmortar side. | +| **TDOF** | True degree of freedom. In MFEM parlance, the global, uniquely-owned (after parallel partition) displacement components. Distinct from local LDOFs that include shared/ghost copies. | +| **K** | The tangent stiffness operator. Linear elastic in our prototype; nonlinear (e.g. crystal plasticity) in the eventual ExaConstit deployment. We treat K strictly as an `mfem::Operator` — never gathered to CSR for the actual solve, never assumed to be a `HypreParMatrix`. | +| **Saddle-point system** | The block linear system [[K, Cᵀ], [C, 0]] [u; λ] = [b; 0] (or its Newton-step version). Indefinite — that's why CG is rejected; we use MINRES / GMRES / BiCGStab. | +| **Patch test** | The minimal correctness criterion: a homogeneous RVE under uniform F must produce ũ = 0 to machine precision. If any version of the code fails the patch test, that's a hard fail (not a "pretty close" — exactly zero). | +| **Mortar method** | A weak-coupling FE technique for joining non-matching meshes across an interface. Originally developed for domain decomposition (Bernardi-Maday-Patera), extended to dual basis (Wohlmuth 2000, 2001) for diagonal Schur complement. We use it to enforce ũ(X⁺) = ũ(X⁻) at periodic boundary pairs without requiring the meshes on opposite faces to align. | +| **Wirebasket** | In 3D, the union of edges (the "wires") of the RVE. In a hierarchical PBC formulation, edges are coupled separately from faces and corners are pinned separately from edges, so that each level's constraint complements the next. | +| **Crosspoint** | A geometric point where an edge meets a corner (2D) or a face meets an edge or corner (3D). The dual-basis support of the nonmortar-side mortar Lagrange multipliers must be modified at crosspoints (Wohlmuth's modification, Lopes Eq. C.2 and §4.4.2). | +| **Method C, Method D** | Two different ways to assemble the mortar PBC system. See §2. We use Method D for the prototype. | +| **Total Lagrangian** | A kinematic framework where every operation (FE assembly, gradient evaluation, integration, projection) happens with respect to the *reference* (undeformed) configuration. This is what we use everywhere except visualisation. | +| **Updated Lagrangian** | An alternative where the reference configuration *resets* to the current configuration at each load step. ExaConstit is updated-Lagrangian at the *macroscopic* time-step level: at the end of each step the converged kinematic state becomes the new "reference" for the next step's stress evaluation. Conceptually distinct from the discretization; relevant when planning the C++ port. | + +Notational convention used throughout: +- Bold lower-case for vectors (**u**, **F**), bold upper-case for tensors / matrices when no ambiguity. +- Subscripts c / u distinguish *constrained* / *unconstrained* DOFs (essential / free in the FE-jargon sense). +- Superscripts n, n+1 index load steps. +- "Step" without further qualification means *load step*. "Iteration" means *Newton iteration* within a load step. + +--- + +# §1. The big picture: what computational homogenization needs from PBC + +A computational homogenization scheme handles a multiscale solid mechanics +problem by replacing a real, microscopically-heterogeneous material with an +*effective* macroscopic one, whose constitutive behaviour is queried by solving +a microscale BVP on a *Representative Volume Element* (RVE) at every macroscopic +quadrature point. + +Consider the macro problem at a single Gauss point. The macro solver hands us a +deformation gradient **F**. We must: + +1. **Apply F to the RVE.** Specifically, drive the RVE's displacement field so + that the volume-averaged deformation gradient equals F. +2. **Solve equilibrium on the RVE.** Equilibrium under whatever constitutive + law lives in the RVE (linear elastic, neo-Hookean, crystal plasticity, …). +3. **Read back homogenized stress.** ⟨P⟩_Ω = (1/V) ∫_Ω P dV gives the macro + first Piola-Kirchhoff stress to send back to the macro solver. +4. **Read back homogenized tangent.** ⟨∂P/∂F⟩_Ω. Required for Newton at the + macro level. + +Step 1 is where PBC enters. Three requirements pin down what "apply F" means: + +- **Average theorem.** ⟨F⟩_Ω = F_macro. By Hill-Mandel, this requires either + (a) prescribed displacement u = F·X on ∂Ω, or + (b) prescribed traction t = F^{-T}·N on ∂Ω, or + (c) Ω-periodic boundary conditions where u(X⁺) − u(X⁻) = (F − I)·(X⁺ − X⁻). +- **Periodicity is the canonical choice.** It minimizes the geometric stiffness + artefact of the boundary, gives physically meaningful effective properties, + and is the choice both Lopes (2021) and Miehe (2003) advocate. +- **Decomposition.** Write u = u_lin + ũ where u_lin = (F − I)X. By + construction, periodicity of ũ — i.e. ũ(X⁺) = ũ(X⁻) — is equivalent to + the periodic jump condition on u above. + +The fluctuation ũ is what the FE solver actually computes. The art is in +discretizing the periodicity constraint on ũ, especially when the meshes on +opposite faces do not match. **That's what the mortar method buys us.** + +Why non-matching meshes matter: + +- For axis-aligned hex/quad meshes that we generate ourselves, opposite faces + match by construction, and "node-coupled PBC" works (literally identify TDOFs + on opposite-face node pairs). +- But for any geometry generated by a meshing tool (NETGEN, gmsh, Tetgen) on a + general RVE, the face meshes won't match. A naive PBC implementation fails + silently (or worse: it accepts the mismatch as a valid pair and produces + wrong answers). +- Mortar methods enforce the coupling *integrally*: ∫_Γ ψ ⊗ (ũ⁺ − ũ⁻) ds = 0 + for all test functions ψ in some space. The space of choice is a *dual basis* + (Wohlmuth) — see §4. + +A working PBC implementation must: + +1. Identify the periodic boundary pairs (corner/edge/face geometric structure). +2. Build a constraint matrix C such that C·u_total = 0 enforces ũ + periodicity, with appropriate handling of crosspoints. +3. Pin enough modes to remove rigid-body translation (4 corners × 2 components + in 2D = 8 essential TDOFs; 8 × 3 = 24 in 3D). +4. Embed C·u = 0 into the BVP — typically as a Lagrange-multiplier saddle-point + system. +5. Pass the patch test exactly. +6. Reproduce ⟨F⟩ = F_macro to machine precision (volume-averaged-F + diagnostic). +7. Solve scalably, not just on toy meshes. + +The prototype satisfies (1)-(6) in 2D for both conforming and intentionally +non-matching meshes, with linear elasticity. (7) is in scope for the C++ port. + +--- + +# §2. Two formulations: Method C vs Method D, and why we use D + +This is the most-misunderstood point in the literature, where carelessness +during implementation produces silent errors that *only* show up as ⟨F⟩ +deviating from F_macro by some O(1) amount. Both methods are well-defined and +mathematically valid; they differ in *which displacement field is the unknown* +and consequently in *what the Dirichlet and constraint conditions look like*. +Lopes (2021) §3.3 enumerates them as Methods A through D; we summarize C and D +because those are the only two relevant for our prototype. + +## §2.1 Method C: solve for the fluctuation directly + +**Primal:** ũ (the periodic fluctuation). + +**System:** + +- Unknown: ũ on Ω. +- Equilibrium (linear-elastic case for clarity): + K_uu·ũ + K_uc·ũ_c = − K_uu·u_lin − K_uc·u_lin,c on free DOFs +- Essential BC: ũ_c = 0 at the chosen pinning corners. +- Constraint: C·ũ = 0 (mortar periodicity of the fluctuation). + +After solving, total displacement is u = u_lin + ũ. + +In Method C the corner Dirichlet is "ũ = 0 at corners" — *not* u = u_lin at +corners. The affine field u_lin is a known offset that's never an unknown. + +**When Method C is convenient:** when the FE infrastructure naturally treats +ũ as the field (e.g. if the user wrote a separate FE assembly that takes u_lin +as a fixed body-force-like contribution and solves only for ũ). + +**When Method C is awkward:** standard FE codes (MFEM, libMesh, deal.II) work +on the *total* displacement field. Method C requires special handling to avoid +double-counting u_lin. + +## §2.2 Method D: solve for the total displacement, with corners pinned at u_lin[corner] + +**Primal:** u (the total displacement). + +**System:** + +- Unknown: u on Ω. +- Equilibrium: K·u = 0 (no body force in our setting). +- Essential BC: u_c = u_lin[corner] = (F − I)·X_corner at the chosen pinning corners. +- Constraint: a periodicity condition that, after corner BC, produces the + correct ũ-periodic answer. + +In Method D the corner Dirichlet *is* the affine-corner-displacement: when we +say "corners pinned", we mean u(X_corner) = (F − I) X_corner exactly. + +**Initial iterate:** ũ⁰ = 0, so u⁰ = u_lin everywhere. The Newton step solves +for du = u_tilde with C·du = 0 (a fluctuation-periodicity reading) and total u = u_lin + du. + +This is the convention Lopes uses (his Remark 1, line 342: "The linear +displacement part is applied to the entire RVE domain in the first stage as an +initial guess"). It maps cleanly to ExaConstit's formulation, where the primal +is the full kinematic state and Dirichlet BCs are applied at their full +prescribed values, not as deltas. + +## §2.3 Why we picked Method D (and what's subtle about it) + +Method D is what works inside MFEM's `ParBilinearForm` / `ParNonlinearForm` +infrastructure without painful workarounds. The total field is the natural +unknown; standard `EliminateRowsCols` handles the corner Dirichlet; the +constraint matrix C couples *fluctuation* DOFs (which after corner elimination +are the only thing the constraint sees). + +The subtlety: + +1. **C operates on the fluctuation, but the primal is the total.** This sounds + trivial but caused a real bug. When we compute the right-hand side of the + linear solve, we want `r1 = K·u_lin` (with corner entries zeroed). After + corner elimination, the eliminated K has zero columns at the corner + positions, so `K_eliminated·u_lin` *loses* the K_uc·u_lin[corner] term that + couples free rows to corner displacements. **Use the full (un-eliminated) K + to compute r1, then zero corner entries of r1.** See §6.4 and the §12 trap + list. Forgetting this gives the patch test the appearance of working + (Krylov converges, constraint residual is small, SciPy direct cross-checks + match — but they all match the *wrong* answer, with free DOFs collapsing + toward zero instead of following u_lin). + +2. **The constraint as seen by the saddle-point solve has corners zeroed + out.** The corner cols of C are zeroed by `apply_dirichlet_zero_to_C`, + because the corner DOFs are essential and shouldn't appear in the + constraint. (After corner elimination from K, those columns of the saddle- + point top block would be zero anyway; we zero C's cols defensively.) This + places us in a Method-C reading at the constraint level — `C·du = 0` — + while the primal-level interpretation is Method D — `u_total = u_lin + du`. + The two readings are equivalent modulo the affine offset; the implementation + is consistent as long as both halves agree on the sign convention. + +3. **What changes between load steps.** In a multi-step ramp F^{n+1} ≠ F^n, + the *corner displacements* change because u_lin = (F−I)X changes. The + prescribed-Dirichlet values for the corners thus shift step-to-step. Hence + the warm-start projection (§7) has to handle a "Δu at the essential + corners" injection — which is exactly the pattern ExaConstit's `SolveInit` + handles for velocity primal; we translate it to displacement primal. + +## §2.4 What killed the wrong RHS in the multi-step driver + +The first multi-step driver implementation used `K_eliminated·u_lin` as the RHS +inside the driver class because the eliminated K was the only K the driver had +been handed. This produced answers where, in heterogeneous RVEs, free DOFs +appeared to be moving in the *opposite* direction of u_lin (the user spotted +the symptom in ParaView). The fix was to pass two K-handles into the driver: +`K_full` (un-eliminated, used for the RHS) and `K_eliminated` (used as the +saddle-point's top block). See §6.4 for the full derivation and §12 trap 11 +for the bug description. + +--- + +# §3. The mortar method — variational form, discrete construction, algorithm + +The mortar method is the canonical weak-coupling FE technique for joining +non-matching meshes across an interface. We give the *minute version* first +(for orientation), then the continuous variational form (with citations +[Bernardi et al. 1994; Wohlmuth 2000, 2001]), then the discrete construction +that produces the rows of our constraint matrix C, and finally the explicit +geometric-matching algorithm in pseudocode. + +## §3.1 The minute version + +You have two interfaces Γ⁺ and Γ⁻ that should be identified periodically. Their +meshes don't match. You want a constraint that says *the displacement fields +agree on the interface in a weak sense*. Mortar method: + +1. Pick the nonmortar (B, "−") side. +2. Choose a Lagrange-multiplier space Λ_h on the nonmortar side. Each basis + function μ_i ∈ Λ_h corresponds to one row of the constraint matrix C. +3. Build C row-by-row by computing ∫_{Γ⁻} μ_i · (u⁺ − u⁻) ds, expressed in + terms of mortar / nonmortar FE shape functions. +4. The whole interface then gets one row per nonmortar-side multiplier DOF per + spatial component. C has (#LM rows) columns equal to (#displacement TDOFs) + and a sparsity pattern that's local to each nonmortar-side element plus its + mortar-side image. + +After C is built, embed the constraint into the BVP via Lagrange multipliers: +[[K, Cᵀ], [C, 0]] [u; λ] = [b; 0]. (See §6.) + +## §3.2 The continuous variational form + +Let Ω be the RVE domain with boundary ∂Ω. Periodicity identifies pairs of +opposite parts of ∂Ω; for each pair, denote the two halves by Γ⁺ (mortar / +"plus" side) and Γ⁻ (nonmortar / "minus" side). The periodic mapping +Π : Γ⁻ → Γ⁺ relates the geometric image of each nonmortar point to its mortar +counterpart. For an axis-aligned cube of side L, Π is a pure translation by +±L along the appropriate coordinate axis. + +The continuous fluctuation-periodicity condition reads, in strong form, + + ũ(X) = ũ(Π(X)), X ∈ Γ⁻. (3.1) + +This is what we want to enforce, but it is too strong to hold pointwise on a +mesh whose Γ⁻ and Γ⁺ traces don't match. The mortar method weakens (3.1) by +testing it against a Lagrange-multiplier space Λ ⊂ [L²(Γ⁻)]^d (one component +per spatial dimension d). The weak form is + + ∫_{Γ⁻} μ · ( ũ ∘ Π − ũ|_{Γ⁻} ) ds = 0 ∀ μ ∈ Λ. (3.2) + +When (3.2) holds for every μ in a sufficiently rich Λ, the difference +ũ ∘ Π − ũ|_{Γ⁻} is L²(Γ⁻)-orthogonal to Λ. The discrete choice of Λ_h ⊂ Λ +determines exactly *which* discrete projection of (3.1) is enforced; this +choice is the methodological lever the mortar method gives us. + +The full RVE BVP, in mixed Lagrange-multiplier form, is then [Lopes et al. +2021, §3.2]: + +> Find (u, λ) ∈ V × Λ such that +> +> a(u, v) − ⟨λ, [v]⟩_{Γ⁻} = ⟨f, v⟩ ∀ v ∈ V (3.3a) +> ⟨μ, [u]⟩_{Γ⁻} = 0 ∀ μ ∈ Λ (3.3b) +> +> where: +> +> - V is the FE space (with corner Dirichlet BCs imposed strongly), +> - a(u, v) is the bilinear form of the elasticity problem +> (a(u, v) = ∫_Ω σ(u) : ε(v) dV in the linear-elastic case), +> - [v] := v ∘ Π − v|_{Γ⁻} is the periodic jump on Γ⁻, +> - ⟨·,·⟩_{Γ⁻} is the L²(Γ⁻) duality pairing. + +Equation (3.3a) is the equilibrium with the constraint reaction Cᵀλ +appearing on the LHS. Equation (3.3b) is the (weak) periodicity. Together +they give the saddle-point system [[K, Cᵀ], [C, 0]] of §6. + +## §3.3 The discrete formulation: deriving the rows of C + +Discretize V with the standard FE space V_h (continuous H¹ piecewise +polynomials, vector-valued, vdim = d). On Γ⁻ the trace of V_h has shape +functions {N_j^⁻}; on Γ⁺ the trace has {N_k^⁺}. Choose Λ_h spanned by +multiplier basis functions {μ_i} on Γ⁻ — for the dual-basis mortar method +these are the *dual* of {N_j^⁻} (see §4 for the explicit construction). + +Substituting u_h = ∑ N_j^⁻ u_j^⁻ + ∑ N_k^⁺ u_k^⁺ + (interior-only DOFs) into +(3.3b): + + ⟨μ_i, u_h ∘ Π − u_h|_{Γ⁻}⟩ + = ∑_k ( ∫_{Γ⁻} μ_i (N_k^⁺ ∘ Π) ds ) u_k^⁺ + − ∑_j ( ∫_{Γ⁻} μ_i N_j^⁻ ds ) u_j^⁻ + = 0. (3.4) + +Define two element-level matrices: + + D_{ij} := ∫_{Γ⁻} μ_i N_j^⁻ ds (3.5a) + A^m_{ik} := ∫_{Γ⁻} μ_i (N_k^⁺ ∘ Π) ds (3.5b) + +D is the *nonmortar-side mass matrix* against the multiplier basis. A^m +("mortar matrix") is the mortar-side coupling: it integrates the +multiplier μ_i (defined on Γ⁻) against the mortar shape function N_k^⁺ +evaluated at Π(X) (the periodic image of the nonmortar point X). + +The discrete form of (3.3b) is then, in matrix-vector notation, + + A^m · u^⁺ − D · u^⁻ = 0, (3.6) + +per spatial component. Each component (x, y, z) gets its own copy of +(3.6); the constraint for a vector-valued field stacks them block- +diagonally. + +The full constraint matrix C is built by assembling the contributions from +all nonmortar-side elements: + + C = [ −D | A^m | 0 | … ] (3.7) + +where the columns are organized as [nonmortar-side DOFs | mortar-side DOFs | +interior DOFs]. The interior DOFs have zero entries (the constraint +involves only boundary values). The signed structure says: the constraint +row enforces (mortar-side LM-weighted) = (nonmortar-side LM-weighted), i.e. +A^m u^⁺ = D u^⁻ from (3.6). + +**Why dual basis matters here.** If we choose the multiplier space +Λ_h = trace(V_h) — the standard mortar method [Bernardi et al. 1994] — +then μ_i = N_i^⁻, and D becomes the nonmortar-side FE mass matrix (full, +banded, not diagonal). The Schur complement C diag(K)⁻¹ Cᵀ is then +dense within the nonmortar-side support. If we instead choose Λ_h to be +*biorthogonal* to {N_j^⁻} on Γ⁻ — Wohlmuth's dual mortar approach +[Wohlmuth 2000] — then by construction D is diagonal, and inversion in +(3.6) (or condensation of λ from the saddle-point system in §6) becomes +element-local. This is the architectural payoff for the dual basis. + +## §3.4 Standard mortar vs dual-basis mortar + +Two flavours: + +- **Standard mortar** [Bernardi, Maday & Patera 1994]: Λ_h = trace(V_h) + modulo boundary conditions. The matching condition (3.4) becomes a + global linear system involving the nonmortar-side FE mass matrix D. Optimal + a priori error estimates O(h^{p+1}) for p-th order FE. Schur complement + is dense and ill-conditioned in 3D. + +- **Dual-basis mortar** [Wohlmuth 2000, 2001]: Λ_h is the dual basis, + bi-orthogonal to {N_j^⁻} on Γ⁻, supported in only a few elements. D is + diagonal. C·M⁻¹·Cᵀ becomes sparse and banded, with bandwidth equal to + the multiplier-mortar coupling support. Same a priori error estimates as + standard mortar [Wohlmuth 2000, Theorem 4.1]. + +We use dual-basis mortar throughout. The dual basis is what makes the +multiplier-block elimination tractable in 3D and is the right starting point +for the eventual ExaConstit production solver. The construction generalises +to triangles and tetrahedra (see §4.4–§4.5) and to higher-order elements +[Lamichhane & Wohlmuth 2002; Popp et al. 2012]. + +## §3.5 Geometric matching: nonmortar quadrature → mortar interpolation + +The hardest geometric piece is the realisation of the integral in (3.5b). +For each nonmortar-side element (line segment in 2D, quad-4 or tri-3 face in +3D), the basic algorithm is: + +``` +for each nonmortar-side element S in Γ⁻: + fe_S = nonmortar element shape data (N_j^⁻, dual basis μ_i, parametric domain) + place a Gauss quadrature rule {(ξ_q, w_q)} on S's reference domain + for each Gauss point q: + x_q = nonmortar element transformation T_S(ξ_q) # physical point + x_mortar = Π(x_q) # periodic image + find mortar element M containing x_mortar + compute ξ_mortar = inverse transformation T_M⁻¹(x_mortar) + evaluate nonmortar dual basis μ_i(ξ_q) for i in nonmortar-LM DOFs + evaluate nonmortar shape N_j^⁻(ξ_q) for j in nonmortar DOFs + evaluate mortar shape N_k^⁺(ξ_mortar) for k in mortar DOFs + |J_S| = element Jacobian determinant at ξ_q + for i, j: D_local[i,j] += w_q · |J_S| · μ_i(ξ_q) · N_j^⁻(ξ_q) + for i, k: A^m_local[i,k] += w_q · |J_S| · μ_i(ξ_q) · N_k^⁺(ξ_mortar) + assemble D_local into D (global, with appropriate row/column TDOF maps) + assemble A^m_local into A^m +``` + +Two key properties of this algorithm: + +1. **Quadrature is on the nonmortar element's reference domain.** All FE + shape and dual-basis values are evaluated at nonmortar-element parametric + points. The mortar is *evaluated* at the projected point, not + integrated against. + +2. **The integration domain is the nonmortar element**, not its intersection + with the mortar. The variational form (3.4) integrates over Γ⁻ in its + entirety; even if a nonmortar element overlaps multiple mortar elements + (non-conforming case), each Gauss point is processed individually with + its own mortar-element lookup. We do *not* need polygon-clipping in + the algorithm above — quadrature on the nonmortar reference suffices for + any non-conforming pair, conforming or otherwise. + + *Caveat for sub-element accuracy:* if a nonmortar element is much larger + than the mortar elements it overlaps, a single Gauss rule on the + nonmortar may not resolve the mortar-side discontinuities (jumps in + ∇N_k^⁺) at element boundaries. In that case the integration must be + *sub-divided* at the mortar-element boundaries — this is where + Sutherland-Hodgman polygon clipping enters (§3.7). For our 2D + prototype we use a sufficient-order quadrature on the un-clipped + nonmortar element, which is acceptable when the meshes have comparable + refinement; for production 3D this will need clipping. + + *The D-vs-A^m domain split (important).* When we do sub-divide for + the non-conforming case, the integration domain depends on which + matrix entry we're computing: + + - **D contributions (`D_kk = ∫_Γ⁻ μ_k N_k⁻ dA`)** are accumulated PER + NONMORTAR ELEMENT, with the integration domain being the FULL + nonmortar element. They depend only on nonmortar-element shape data + — there is no mortar-side input, hence no need to know which sub- + polygon any quadrature point falls into. Computing D directly on + the full element (`D_k = ∫_E N_k dA`, exploiting the dual-basis + biorthogonality identity that lumps μ_k against N_k) avoids + compounding rounding error and is computationally cheaper. + - **A^m contributions (`A^m_kl = ∫_Γ⁻ μ_k (N_l⁺ ∘ Π) dA`)** are + accumulated PER CLIPPED OVERLAP, with the integration domain being + the OVERLAP polygon (a sub-region of the nonmortar element). They + require evaluating the mortar-side shape function `N_l⁺` at the + projected point, which only makes sense within a specific mortar + element. Each overlap polygon is fan-triangulated and quadratured + per sub-triangle. + + Why this split is correct: Wohlmuth's biorthogonality identity + `∫_E μ_i N_j dE = δ_ij ∫_E N_i dE` holds when integrated over the + FULL nonmortar element E, NOT segment-wise. So we compute D directly + as `∫_E N_i` (a cheap element-local quadrature) rather than as + `∑_segments ∫ μ_i N_i` (which would compound rounding error and + requires summing all overlapping segments correctly). + + The 2D code in `mortar_pbc/mortar_2d.py` implements this split (D + per full nonmortar segment, A^m per overlap segment) and the C++ + port in `mortar_assembler_2d.cpp` mirrors it. The 3D non-conforming + port (Phase 3.5 / Phase 4.4) extends the same pattern. + +For axis-aligned periodic boundaries (our case), the geometric matching +simplifies dramatically: + +- **2D**: a nonmortar point at (x, 0) maps via Π to (x, L). Local search on + the mortar is a 1D parameter-space search along the y = L edge. +- **3D**: a nonmortar point on the y = 0 face at (x, 0, z) maps to (x, L, z). + Two-parameter (ξ, η) search on the mortar quad face (or barycentric + search on a mortar triangle face). + +The current 2D code (`mortar_pbc/mortar_2d.py`) handles step 4 of the +algorithm via direct 1D parameter search. The 3D code (Phase 3.2–3.3) +needs the 2D analog. For *conforming* meshes in 3D, the mortar-element +lookup is by direct geometric indexing; for *non-conforming* (Phase 3.5) +it requires the AABB-tree-or-similar lookup plus the clipping subroutine. + +## §3.6 The conforming "free-pass" case + +When the nonmortar and mortar meshes match node-for-node on the periodic +interface, every nonmortar Gauss point lands on a mortar element such that +ξ_mortar = ξ_nonmortar (modulo the orientation of the parametric coordinate +on opposite faces). Then evaluating mortar shape functions N_k^⁺ at +ξ_mortar gives the same values as evaluating nonmortar shape functions +N_j^⁻ at ξ_nonmortar (same FE family, same parametric coordinate). For dual +basis with bi-orthogonality: + + D_{ii} = ∫_{Γ⁻} μ_i N_i^⁻ ds = (∫_{Γ⁻} N_i^⁻ ds) (3.8a) + A^m_{ik} = ∫_{Γ⁻} μ_i (N_k^⁺ ∘ Π) ds = (∫_{Γ⁻} N_i^⁻ ds) δ_{ik} (3.8b) + +(see §4.2 for why the bi-orthogonality gives a row-sum-equal-to-N-integral +structure). Hence after the row-scaling D⁻¹ implicit in (3.6), the +constraint reduces to + + A^m_{normalized} u^⁺ − u^⁻ = 0, A^m_{normalized} = identity-with-sign-on-pair + +i.e. one row per nonmortar DOF, with +1 on the nonmortar-DOF column and −1 on the +mortar-DOF column. This is the "lumped" or "node-coupled" PBC — the same +answer a hand-crafted node-pair-identification PBC would give. + +The conforming case is therefore a useful *correctness baseline*: build a +trivially conforming RVE, check that C is exactly the signed-identity +structure (modulo Wohlmuth corner mods, §5), run the patch test. + +The 2D `test_conforming_pair_recovers_lumping` unit test exists for +exactly this purpose. Phase 3.2 will need the 3D analog (one for +quad-face conforming pairs, one for tri-face conforming pairs). + +## §3.7 Aside: Sutherland-Hodgman polygon clipping (Phase 3.5 preview) + +For non-conforming face pairs in 3D where nonmortar-element / mortar-element +overlap is non-trivial, the integral (3.5b) must be sub-divided to capture +mortar-side basis discontinuities. Sutherland-Hodgman [Sutherland & Hodgman +1974] gives a robust convex-on-convex clipping algorithm, applicable to +quad-on-quad and tri-on-tri (and mixed) face overlaps: + +``` +function sutherland_hodgman_clip(subject_polygon, clip_polygon): + # subject_polygon: vertices of the nonmortar element (in mortar-local coords) + # clip_polygon : vertices of one mortar element (assumed convex) + output = subject_polygon + for each edge (e1, e2) of clip_polygon: + input = output + output = [] + for each pair of consecutive vertices (s, p) in input: + if p is inside_halfplane(e1, e2): + if s is not inside_halfplane(e1, e2): + output.append(intersection(s, p, e1, e2)) + output.append(p) + else: + if s is inside_halfplane(e1, e2): + output.append(intersection(s, p, e1, e2)) + if output is empty: return [] # no overlap + return output +``` + +The clipped polygon is then triangulated (fan-triangulation works for the +convex case) and Gauss quadrature is placed on each sub-triangle. The +mortar-element basis is evaluated at the projected sub-triangle Gauss +points, the nonmortar-element basis at the inverse-projected points. The +contributions accumulate into the same D and A^m as before. + +This algorithm handles: +- **Quad nonmortar on quad mortar**: 4-on-4, both convex. +- **Tri nonmortar on tri mortar**: 3-on-3, both convex. +- **Mixed**: clip the nonmortar (3 or 4 vertices) by each mortar in turn. + +Hand-rolling Sutherland-Hodgman for these cases is straightforward and +avoids the heavy `shapely` dependency. We defer the implementation to +Phase 3.5; conforming-mesh testing in Phases 3.1–3.4 doesn't need it. + +--- + +# §4. The dual basis: derivation, simplex unification, and explicit formulas + +The dual basis is the algebraic core of Wohlmuth's mortar method +[Wohlmuth 2000, §4.1]. This section derives it from first principles, then +gives the explicit formulas for the four element types we need: + +| Element | Geometry | Volume / Face element of | Citation | +|---|---|---|---| +| **line-2** | 1D segment, 2 nodes | quad-4 / tri-3 (edge); 3D edge mortar | [Wohlmuth 2000; Lopes et al. 2021, Eq. C.1] | +| **tri-3** | 2D triangle, 3 nodes | tet-4 (face); also 2D simplex mesh | [Wohlmuth 2000, §4.1] | +| **quad-4** | 2D bilinear quadrilateral, 4 nodes | hex-8 (face) | [Lopes et al. 2021, Eq. C.3] | +| **tet-4** | 3D tetrahedron, 4 nodes | tet mesh (volume) | [Lamichhane & Wohlmuth 2007] | + +ExaConstit users may run hex meshes (whose periodic faces are quad-4) or tet +meshes (whose periodic faces are tri-3); a single PBC implementation must +support both. Mixed meshes (some hex, some tet) are also allowed in MFEM and +the formulation must accommodate them on a face-by-face basis. + +## §4.0 Derivation from the bi-orthogonality requirement + +The defining property of the dual basis [Wohlmuth 2000, eq. 4.1]: + + ∫_E M_i N_j dE = δ_ij ∫_E N_j dE, i, j = 1, …, n_loc (4.1) + +where E is a single boundary element (line in 2D, tri or quad in 3D) on the +nonmortar side, {N_j} are the standard FE shape functions, and {M_i} is the dual +basis we are constructing. The right-hand side is the *standard FE shape +function integral*, not the FE mass matrix entry — this is what makes the +dual basis "biorthogonal to N with respect to a diagonal target". + +Constructive ansatz: write each M_i as a linear combination of the same +shape functions, + + M_i = ∑_j A_ij N_j, (4.2) + +where A is an n_loc × n_loc matrix to be determined. Substituting (4.2) +into (4.1): + + ∑_k A_ik ∫_E N_k N_j dE = δ_ij ∫_E N_j dE (4.3) + +Define the **standard FE mass matrix** M^FE on E and the **shape integral +vector** s: + + M^FE_kj := ∫_E N_k N_j dE, s_j := ∫_E N_j dE (4.4) + +Then (4.3) becomes the matrix equation + + A · M^FE = diag(s), so A = diag(s) · (M^FE)⁻¹. (4.5) + +This is the algebraic core. Once we know M^FE and s for a given reference +element, we get A explicitly by inverting M^FE and right-multiplying by +diag(s). The dual basis is then just (4.2): each M_i is a linear combination +of the FE shape functions on the same element. + +**Local support.** Each M_i is supported on exactly the same elements as +N_i — element-local, just like the FE basis [Wohlmuth 2000, Theorem 4.2]. +This is why the discrete D matrix becomes diagonal: D_{ii} = s_i ≠ 0 by +(4.1), and D_{ij} = 0 for j ≠ i. + +**Partition of unity.** A direct consequence of (4.1) and ∑_j N_j = 1 is: + + ∑_i M_i(x) = 1 ∀ x ∈ E. (4.6) + +Proof: at any x ∈ E, write the constant function 1 = ∑_j N_j(x). Then +∫_E (∑_i M_i) N_j dE = ∑_i ∫_E M_i N_j dE = s_j (one term, i = j survives by +(4.1)) = ∫_E N_j dE = ∫_E 1 · N_j dE. Since the {N_j} span all polynomials +of total degree 1 on simplices (or bilinear functions on hypercubes), and +since ∑_i M_i is in the same span, the equality of integrals against every +N_j forces ∑_i M_i = 1 pointwise. ∎ + +This partition-of-unity property is what guarantees *constant reproduction* +across non-conforming pairs: if ũ⁻ ≡ const on Γ⁻ and ũ⁺ ≡ const on Γ⁺, then +the constraint row ∫ μ_i (u⁺ ∘ Π − u⁻) ds = 0 is satisfied automatically. + +## §4.1 Simplex unification: line-2, tri-3, tet-4 + +For a *d-dimensional simplex* (d=1: line; d=2: triangle; d=3: tetrahedron), +the standard P1 shape functions are the barycentric coordinates λ_1, …, +λ_{d+1}. The integrals (4.4) on the reference simplex of measure |E| are +[Strang & Fix 1973, §3.2]: + + ∫_E λ_i dE = |E| / (d+1) (4.7a) + ∫_E λ_i² dE = 2 |E| / [(d+1)(d+2)] (4.7b) + ∫_E λ_i λ_j dE = |E| / [(d+1)(d+2)], i ≠ j (4.7c) + +So M^FE has the structure (M^FE)_ij = α + β δ_ij where + + α = |E| / [(d+1)(d+2)], β = |E| / [(d+1)(d+2)]. + +That is, M^FE = α (1_(d+1) 1_(d+1)ᵀ + I), which has rank-1 plus identity +structure. Its inverse is computed by the Sherman-Morrison identity: + + (M^FE)⁻¹ = (1/α) · [I − (1/(d+2)) 1 1ᵀ]. (4.8) + +Combining with diag(s) = (|E| / (d+1)) I: + + A = diag(s) · (M^FE)⁻¹ + = [|E|/(d+1)] · (1/α) · [I − 1 1ᵀ / (d+2)] + = (d+2) · [I − 1 1ᵀ / (d+2)] + = (d+2) I − 1 1ᵀ (4.9) + +Therefore A_ii = d+1 (diagonal) and A_ij = −1 (off-diagonal). Substituting +back into (4.2): + + M_i = (d+1) N_i − ∑_{j≠i} N_j = (d+1) N_i − (1 − N_i) = **(d+2) N_i − 1** + (4.10) + +This single closed form covers all three simplex cases: + +| d | Element | Formula | Verified at | +|---|---|---|---| +| 1 | line-2 | M_i = 3 N_i − 1 | §4.2 | +| 2 | tri-3 | M_i = 4 λ_i − 1 = 4 N_i − 1 | §4.4 | +| 3 | tet-4 | M_i = 5 λ_i − 1 = 5 N_i − 1 | §4.5 | + +Equation (4.10) is much cleaner than the mixed forms in [Lopes et al. 2021] +and matches [Lamichhane & Wohlmuth 2007, eq. 3.4] for the linear simplex +case. The tensor product for hypercubes (line-2 ⊗ line-2 = quad-4, etc.) +does not collapse to (4.10); it is its own structure (§4.6). + +## §4.2 The line-2 dual basis (1D simplex, d=1) + +Reference element: ξ ∈ [−1, +1], measure |E| = 2. + +Standard shape functions: + + N_1(ξ) = (1 − ξ) / 2, N_2(ξ) = (1 + ξ) / 2 (4.11) + +By (4.10) with d=1: + + M_i(ξ) = 3 N_i(ξ) − 1 (4.12) + +which gives explicitly + + M_1(ξ) = 3 · (1−ξ)/2 − 1 = (3 − 3ξ − 2) / 2 = (1 − 3ξ) / 2 (4.13a) + M_2(ξ) = 3 · (1+ξ)/2 − 1 = (1 + 3ξ) / 2 (4.13b) + +This matches [Lopes et al. 2021, Eq. C.1] exactly. Verification by direct +integration (no factor of 1/2 mistakes — the line measure on [−1,1] is dξ): + + ∫_{−1}^{+1} M_1 N_1 dξ = ∫_{−1}^{+1} (1 − 3ξ)(1 − ξ) / 4 dξ + = (1/4) ∫_{−1}^{+1} (1 − 4ξ + 3ξ²) dξ + = (1/4) [2 − 0 + 2] = 1 (4.14a) + + ∫_{−1}^{+1} M_1 N_2 dξ = (1/4) ∫_{−1}^{+1} (1 − 3ξ)(1 + ξ) dξ + = (1/4) ∫_{−1}^{+1} (1 − 2ξ − 3ξ²) dξ + = (1/4) [2 − 0 − 2] = 0 (4.14b) + +And ∫_{−1}^{+1} N_1 dξ = ∫_{−1}^{+1} (1−ξ)/2 dξ = 1, so ∫ M_1 N_1 = ∫ N_1 +holds — the diagonal target value is the shape integral, as (4.1) requires. +Symmetric calculations confirm M_2. + +The implementation in `mortar_pbc/mortar_2d.py`: + +```python +def N_line2(xi: float) -> tuple[float, float]: + """Standard line-2 shape functions on [-1, +1].""" + return ((1.0 - xi) * 0.5, (1.0 + xi) * 0.5) + +def M_line2_dual(xi: float) -> tuple[float, float]: + """Lopes Eq. C.1 / Wohlmuth (2000) line-2 dual basis.""" + return ((1.0 - 3.0 * xi) * 0.5, (1.0 + 3.0 * xi) * 0.5) +``` + +Verified by `test_dual_basis_biorthogonality` to machine precision. + +## §4.3 The quad-4 dual basis (2D hypercube, d=2 tensor product) + +Reference element: ξ, η ∈ [−1, +1]², measure |E| = 4. + +Standard shape functions (tensor product of line-2): + + N_1(ξ,η) = (1−ξ)/2 · (1−η)/2 (corner (−1,−1)) (4.15a) + N_2(ξ,η) = (1+ξ)/2 · (1−η)/2 (corner (+1,−1)) (4.15b) + N_3(ξ,η) = (1+ξ)/2 · (1+η)/2 (corner (+1,+1)) (4.15c) + N_4(ξ,η) = (1−ξ)/2 · (1+η)/2 (corner (−1,+1)) (4.15d) + +Tensor product dual basis [Lopes et al. 2021, Eq. C.3]: + + M_quad4_i(ξ,η) = M_line2_p(ξ) · M_line2_q(η) (4.16) + +where (p, q) ∈ {(1,1), (2,1), (2,2), (1,2)} for i = 1, 2, 3, 4 respectively. + +Bi-orthogonality follows from the 1D bi-orthogonality and Fubini's theorem: + + ∫∫ M_quad4_i N_quad4_j dξ dη + = (∫ M_line2_p(ξ) N_line2_p'(ξ) dξ) · (∫ M_line2_q(η) N_line2_q'(η) dη) + = δ_pp' · δ_qq' (4.17) + +where (p', q') indexes node j the same way (p, q) indexes node i. The +identity is δ_ij = δ_pp' δ_qq' modulo the corner-numbering convention. + +Partition of unity: M_1 + M_2 + M_3 + M_4 = (M_1^line2(ξ) + M_2^line2(ξ)) · +(M_1^line2(η) + M_2^line2(η)) = 1 · 1 = 1. ✓ + +Explicit form, expanding (4.16) for node 1: + + M_quad4_1(ξ,η) = ((1−3ξ)/2) · ((1−3η)/2) + = (1 − 3ξ − 3η + 9ξη) / 4 (4.18) + +The other three follow by sign changes. + +## §4.4 The tri-3 dual basis (2D simplex, d=2) + +Reference element: standard triangle in barycentric coordinates with +λ_1 + λ_2 + λ_3 = 1, measure |E| (= 1/2 on the unit triangle, but the +formula is element-area-normalised). + +Standard shape functions: N_i = λ_i (i = 1, 2, 3). + +By (4.10) with d=2: + + M_i(λ_1, λ_2, λ_3) = 4 λ_i − 1 (4.19) + +Bi-orthogonality verification using (4.7): + + ∫_E M_1 N_1 dE = ∫_E (4 λ_1 − 1) λ_1 dE + = 4 ∫_E λ_1² dE − ∫_E λ_1 dE + = 4 · 2|E|/(3·4) − |E|/3 + = 4 · |E|/6 − |E|/3 + = 2|E|/3 − |E|/3 = |E|/3 (4.20a) + +And ∫_E N_1 = |E|/3 by (4.7a). Match: ∫ M_1 N_1 = ∫ N_1. ✓ + + ∫_E M_1 N_2 dE = ∫_E (4 λ_1 − 1) λ_2 dE + = 4 ∫_E λ_1 λ_2 dE − ∫_E λ_2 dE + = 4 · |E|/[(3·4)] − |E|/3 + = |E|/3 − |E|/3 = 0 (4.20b) + +✓ Symmetric for the other entries. + +Partition of unity: M_1 + M_2 + M_3 = 4(λ_1 + λ_2 + λ_3) − 3 = 4 − 3 = 1. ✓ + +The implementation, planned for `mortar_pbc/mortar_3d.py` in Phase 3.2: + +```python +def N_tri3(lam: tuple[float, float, float]) -> tuple[float, float, float]: + """Standard tri-3 shape functions = barycentric coordinates.""" + return (lam[0], lam[1], lam[2]) + +def M_tri3_dual(lam: tuple[float, float, float]) -> tuple[float, float, float]: + """Tri-3 dual basis: M_i = 4 N_i - 1. + + Reference: Wohlmuth (2000) Section 4.1; Lamichhane & Wohlmuth (2007) eq. 3.4. + Cite: derived in MORTAR_PBC_ARCHITECTURE.md §4.4. + """ + return (4.0 * lam[0] - 1.0, 4.0 * lam[1] - 1.0, 4.0 * lam[2] - 1.0) +``` + +## §4.5 The tet-4 dual basis (3D simplex, d=3) + +Reference element: standard tetrahedron in barycentric coordinates with +λ_1 + λ_2 + λ_3 + λ_4 = 1. + +Standard shape functions: N_i = λ_i (i = 1, 2, 3, 4). + +By (4.10) with d=3: + + M_i(λ_1, …, λ_4) = 5 λ_i − 1 (4.21) + +Bi-orthogonality verification using (4.7) with d=3, |E| = volume: + + ∫_E λ_i dE = |E| / 4 + ∫_E λ_i² dE = 2|E| / 20 = |E| / 10 + ∫_E λ_i λ_j dE = |E| / 20, i ≠ j + +So: + + ∫_E M_1 N_1 dE = 5 · |E|/10 − |E|/4 = |E|/2 − |E|/4 = |E|/4 = ∫ N_1 ✓ + ∫_E M_1 N_2 dE = 5 · |E|/20 − |E|/4 = |E|/4 − |E|/4 = 0 ✓ + +Partition of unity: M_1 + M_2 + M_3 + M_4 = 5(λ_1+λ_2+λ_3+λ_4) − 4 = 1. ✓ + +Match: [Lamichhane & Wohlmuth 2007, eq. 3.4] for the linear tet case. + +This is the dual basis for **3D edge / face mortar on tet meshes**. A tet +volume element has 4 triangular faces; for face mortar between periodic +faces of a tet RVE, each nonmortar face is a tri-3 element and uses the §4.4 +dual basis (`M_tri3_dual`). The tet-4 dual itself (4.21) is needed only +for *volume* mortar (e.g. cross-mesh patch coupling, not our PBC use case). +We document it here for completeness because it slots into the same +unified simplex formula, and because future ExaConstit features (e.g. +multi-block coupling on internal interfaces) may use it. + +## §4.6 Hypercubes vs simplices: structural differences + +| Property | Simplex (line-2 / tri-3 / tet-4) | Hypercube (quad-4 / hex-8) | +|---|---|---| +| Dual basis shape | M_i = (d+2) N_i − 1 | Tensor product M_line2 ⊗ … | +| Polynomial degree | Total degree 1 in λ_i | Multi-linear (degree 1 in each ξ_k) | +| Bi-orthogonality structure | Eq. (4.10) closed form | Eq. (4.16) tensor structure | +| Partition of unity | (4.6) by direct calculation | Tensor product of 1D version | +| 3D face element ↔ volume element | Tri-3 face ↔ tet-4 volume | Quad-4 face ↔ hex-8 volume | + +For mixed meshes (some hex elements with quad-4 faces, some tet elements +with tri-3 faces), the dual basis is selected per-face: each face inherits +its dual basis from the face element type, not from the volume element. +The mortar assembler must therefore dispatch on `face.geom_type` and apply +the appropriate `M_*_dual` function. This polymorphism is straightforward +to encode in C++ via virtual function dispatch on `mfem::Element::Type`. + +## §4.7 Why bi-orthogonal matters: condition number and Schur complement + +The dual basis is more than algebraic decoration. The diagonality of D +in (3.5a) gives: + +- **D⁻¹** is trivially the diagonal of reciprocals: D_{ii}⁻¹ = 1 / s_i. +- **C M^{−1} Cᵀ ≈ A^m D⁻¹ (A^m)ᵀ** structure: the Schur complement of the + constraint block has a sparsity pattern dictated by A^m alone, not by + D. Each LM row's nonzero pattern is its own A^m row's nonzero pattern. +- **Static condensation** of λ becomes a sparse operation: solving D λ = + rhs is element-local, no global matrix-matrix multiplication. + +For our prototype's saddle-point Krylov path, this matters less directly +(we keep λ as an unknown in the saddle-point system), but the diagonal +block-Jacobi preconditioner on the multiplier block exploits exactly this +structure: diag(C diag(K)⁻¹ Cᵀ) is computed via `WeightedRowSqSum` on the +C operator (see §6.3), which is parallel-safe and works because of the +predictable sparsity that the dual basis induces. + +For the eventual production solver, especially at 3D scale and especially +under mesh refinement, dual-basis mortar is the only practical choice. +Standard mortar [Bernardi et al. 1994] gives a non-diagonal D and a much +denser Schur complement, which scales poorly. See [Wohlmuth 2000, §5; +Wohlmuth 2001, Ch. 1] for detailed condition-number analyses. + +## §4.8 Higher-order: the line-3 dual basis (1D, p = 2) + +In one dimension, the strict bi-orthogonal dual basis exists *at all +orders* p ≥ 1, and is given by an explicit closed form. We work out the +quadratic case (line-3) explicitly because (a) it's the foundational 1D +piece needed by 2D quad-9 / serendipity quad-8 face mortar via tensor +product, (b) it shows the construction (4.5) generalising cleanly when +the lumped diagonal is positive, and (c) it sets up the 2D obstruction +in §4.9 by contrast. + +Reference element: ξ ∈ [−1, +1], measure |E| = 2. + +Standard Lagrange shape functions for the 3-node line element +(corner nodes at ξ = ∓1, mid-node at ξ = 0): + + N_1(ξ) = ½ ξ (ξ − 1) (left corner) (4.22a) + N_2(ξ) = ½ ξ (ξ + 1) (right corner) (4.22b) + N_3(ξ) = 1 − ξ² (mid-node) (4.22c) + +The shape integrals over [−1, +1] (these are the `s` vector of (4.4)): + + s_1 = ∫_{−1}^{+1} N_1 dξ = 1/3 (positive) (4.23a) + s_2 = ∫_{−1}^{+1} N_2 dξ = 1/3 (positive) (4.23b) + s_3 = ∫_{−1}^{+1} N_3 dξ = 4/3 (positive) (4.23c) + +The fact that *all* three are positive is what makes the strict +bi-orthogonal dual exist — see §4.9 for why. The FE mass matrix: + + M^FE = (1/15) · ⎡ 4 −1 2 ⎤ + ⎢−1 4 2 ⎥ (4.24) + ⎣ 2 2 16 ⎦ + +By (4.5), A = diag(s) · (M^FE)⁻¹. Computing (M^FE)⁻¹ and the product +[Lamichhane & Wohlmuth 2002, eq. 3.1]: + + Φ_1(ξ) = (5/24)(5ξ² − 2ξ − 1) (peak at left corner) (4.25a) + Φ_2(ξ) = (5/24)(5ξ² + 2ξ − 1) (peak at right corner) (4.25b) + Φ_3(ξ) = (5/12)(3 − 5ξ²) (peak at mid-node) (4.25c) + +**Verification.** ∫ Φ_1 N_1 dξ = ∫ (5/24)(5ξ² − 2ξ − 1) · ½ ξ(ξ − 1) dξ +expanding and integrating term-by-term over [−1, +1] yields exactly 1/3 += s_1, and ∫ Φ_1 N_2 dξ = 0 = ∫ Φ_1 N_3 dξ. Symmetric for Φ_2, Φ_3. +Strict bi-orthogonality, no relaxation. ✓ + +Partition of unity: Φ_1 + Φ_2 + Φ_3 = (5/24)(5ξ² − 2ξ − 1) ++ (5/24)(5ξ² + 2ξ − 1) + (5/12)(3 − 5ξ²) = (5/24)(10ξ² − 2) ++ (5/12)(3 − 5ξ²) = (50/24)ξ² − 10/24 + 15/12 − (25/12)ξ² += (25/12)ξ² − (25/12)ξ² + (15 − 5)/12 = 1. ✓ + +A subtlety not visible in the linear case: **the dual basis Φ_i is +discontinuous across element boundaries** [Lamichhane & Wohlmuth 2002, +Remark 3.2]. The basis is locally supported (one element of support per +basis function) but its values at element-end nodes from adjacent +elements differ. This is harmless for the mortar saddle-point system — +the LM is an L² object on the nonmortar interface, not an H¹ object — but +it forecloses some smoothness-based stabilisation strategies. To recover +*continuity* without sacrificing strict bi-orthogonality, one applies a +quartic `g(t) ∈ P_4([0,1])` correction satisfying g(t) = −g(1−t), +g(1) = 1, ∫₀¹ g · p dt = 0 ∀ p ∈ P_2 [Lamichhane & Wohlmuth 2002, +Lemma 3.5]. This `g` is one degree higher than the cubic correction +needed for P_1 elements precisely because we now require P_2 +reproduction. + +Tensor-product extension to 2D / 3D: + + Φ^{quad9}_{(i,j)}(ξ, η) = Φ^{line3}_i(ξ) · Φ^{line3}_j(η) (4.26) + Φ^{hex27}_{(i,j,k)}(ξ, η, ζ) = Φ^{line3}_i(ξ) · Φ^{line3}_j(η) · Φ^{line3}_k(ζ) + (4.27) + +These are the **closed-form, strictly bi-orthogonal** dual bases for +biquadratic and triquadratic Lagrangian tensor-product elements. They +slot into the same `M_*_dual` polymorphic dispatch as the linear cases, +with the only architectural change being `M_quad9_dual` returning a +9-tuple and `M_hex27_dual` returning a 27-tuple. + +## §4.9 The bi-orthogonality obstruction at p ≥ 2 on simplices and serendipity elements + +The construction (4.5) `A = diag(s) · (M^FE)⁻¹` *fails* for nodal P_p +Lagrange elements on simplices at p ≥ 2 and for Q^p serendipity elements. +The failure is algebraic, not numerical, and admits a clean general +statement. + +### §4.9.1 The lumped-integral positivity criterion + +**Proposition (lumped positivity).** *The strict bi-orthogonal, +locally-supported dual basis (4.5) exists iff the lumped diagonal +s_j = ∫_E N_j dE is nonzero for every shape function N_j.* + +**Proof sketch.** Equation (4.1) reads ∫ M_j N_j = δ_jj · s_j = s_j on +the diagonal. If s_j = 0, the construction would force ∫ M_j N_j = 0, +which combined with the partition-of-unity ∑_i M_i = 1 yields a +contradiction: integrating the partition of unity against N_j gives +s_j on one side and ∑_i (∫ M_i N_j) = ∫ M_j N_j = 0 on the other (using +bi-orthogonality of off-diagonal terms). The two sides must agree, but +0 ≠ s_j unless we relax bi-orthogonality. Conversely, if all s_j > 0 +(or uniformly nonzero with consistent sign), `diag(s) · (M^FE)⁻¹` is +well-defined and the resulting A has rows that integrate to 1. ∎ + +The lumped diagonal s_j is therefore the diagnostic: **compute s_j for +every shape function N_j on the reference element; if any vanishes, +strict bi-orthogonality with locally supported basis is impossible**. + +### §4.9.2 What goes wrong on tri-6 (and tet-10, quad-8, hex-20) + +For the **tri-6** element with corner shape function +N_1 = λ_1 (2λ_1 − 1) (Lagrange interpolant of degree 2, equal to 1 at +vertex 1 and 0 at the other 2 vertices and 3 mid-edges): + + s_1 = ∫_T λ_1 (2λ_1 − 1) dA + = 2 ∫_T λ_1² dA − ∫_T λ_1 dA + = 2 · (2|T|/12) − |T|/3 (using simplex integrals 4.7) + = |T|/3 − |T|/3 = **0** (4.28) + +The corner-node lumped weight vanishes identically [Popp et al. 2012, +§3.2]. The obstruction is a topological-and-degree fact: the function +λ(2λ − 1) is symmetric about λ = ½ (the boundary midpoint between vertex +and opposite edge in the barycentric simplex), and its integral over +the half-simplex λ ≥ ½ exactly cancels its integral over λ < ½. + +The same calculation gives, for **higher-dimensional simplices**, a +*dimension-dependent* result that we verify here in detail because the +quantitative pattern is different from what one might naively expect: + +For a P_2 corner on a d-simplex (|T| = 1/d!): + + s_corner = 2 ∫ λ² − ∫ λ + = 2 · (2!/(d+2)!) · d! · |T| − (1!/(d+1)!) · d! · |T| + = ((4 / (d+2)!) − (1 / (d+1)!)) · d! · |T| + = (4 − (d+2)) / (d+2)! · d! · |T| + = (2 − d) / ((d+1)(d+2)) · d! · |T|/(d!) wait, simplifying: + = (2 − d) / ((d+1)(d+2)) · |T| [after cleaning up] (4.28b) + +Plugging in d: +- **d=1 (line-3 corner)**: s = (2−1)/(2·3) · 2 = 1/6 · 2 = 1/3 > 0 + (matches §4.8 eq. 4.23a; the strict bi-orthogonal dual exists) +- **d=2 (tri-6 corner)**: s = (2−2)/(3·4) · |T| = 0 + (the boundary case; exactly on the threshold) +- **d=3 (tet-10 corner)**: s = (2−3)/(4·5) · |T| = −|T|/20 = **−1/120** + (genuinely *negative*, not zero — the 2D claim above does not + generalize to 3D) +- **d=4 and higher**: s = (2−d)/((d+1)(d+2)) · |T|, increasingly + negative as d grows. + +The 2D simplex therefore sits exactly on a knife-edge between the +1D-positive and 3D-negative regimes. This is sharper than the +classical "the higher-order simplex dual fails" statement: the sign +of the failure is dimension-dependent, and only in 2D does the corner +integral *vanish* exactly. In 3D it crosses to negative — making +tet-10 structurally similar to the serendipity case (next bullet), +not to the tri-6 case. + +The other failing element types continue: + +- **quad-8 (serendipity)** corner: ∫ N_corner = −|E|/12 [Lamichhane & + Wohlmuth 2004, §3]. The serendipity basis has *no* central bubble + to absorb the corrections, leaving each corner with a negative + lumped diagonal that breaks bi-orthogonality more severely than the + zero-valued tri-6 case. +- **hex-20 (serendipity)** corner: ∫ N_corner < 0 (same mechanism). + +**Why does it not fail on the tensor-product full-Lagrangian +quad-9 / hex-27?** Because the central bubble (and edge-mid bubbles) +absorb mass that would otherwise leave the corner integrals zero or +negative. In barycentric language: the bilinear-times-bilinear +construction of quad-9 has corner shape function +N_1 = ¼ ξ(ξ−1) η(η−1), with ∫_{[-1,+1]²} = (1/3)(1/3) = 1/9 > 0, and +all 9 lumped weights positive. The full-tensor product *retains* +positivity per direction; serendipity loses it by removing the bubble. + +### §4.9.3 The general pattern + +Combining §4.9.1 with the explicit cases: + +| Element type | Strict biorthogonal dual exists? | Why | +|---|---|---| +| **Q^p tensor-product** at any p (line-{p+1}, quad-{(p+1)²}, hex-{(p+1)³}, full-Lagrangian, including NURBS / B-splines) | **Yes** (closed-form via tensor product of 1D dual) | All s_j > 0; tensor structure preserves positivity | +| **P_1 simplex** (line-2, tri-3, tet-4) | **Yes** (eq. 4.10) | s_j = |E|/(d+1) > 0 | +| **P_p simplex at p ≥ 2 in 1D** (line-3, line-4, …) | **Yes** | All s_j > 0 always; line-3 explicit eq. 4.23 has s = (1/3, 1/3, 4/3) | +| **P_2 simplex in 2D** (tri-6) | **Boundary case: no** | s_corner = 0 *exactly* (eq. 4.28); the 2D simplex sits on the knife-edge between 1D-positive and 3D-negative regimes | +| **P_2 simplex in 3D** (tet-10) | **No** | s_corner = −|T|/20 = −1/120 (eq. 4.28b with d=3); negative, similar to serendipity rather than to tri-6 | +| **Q^p serendipity** (quad-8, hex-20) | **No** | Corner s_j < 0 (s_corner_quad8 = −|E|/12; s_corner_hex20 < 0 similarly) | +| **B-spline of degree p ≥ 1** | **Yes** when refined; non-trivial geometric mappings need parametric integration [Wunderlich et al. 2019, arXiv:1806.11535] | Knot-span structure preserves positivity | + +The **dimension-dependent simplex pattern** for P_2 corner shapes +(eq. 4.28b) is: + + s_corner_P2 = (2 − d) / ((d+1)(d+2)) · |T| + +with sign ∈ {+, 0, −} for d ∈ {1, 2, ≥3} respectively. This is sharper +than the textbook "higher-order simplices fail bi-orthogonality": only +the 2D simplex fails by *vanishing*; in 3D it fails by *flipping +sign*, making tet-10 quantitatively similar to the serendipity case +even though the barycentric-Lagrange shape functions have very +different structure. + +This is the predictive rule: **check the lumped integrals s_j. If any +vanishes (P_2 simplex in 2D corners) or is negative (P_2 simplex in +3D+ corners; serendipity corners), strict bi-orthogonality fails and +a relaxation is required**. + +The Lamichhane-Wohlmuth optimal-rate theorem [Lamichhane & Wohlmuth +2007, *Math. Comp.* 76, doi:10.1090/S0025-5718-06-01907-7] gives a +sharper sufficient condition for **polynomial-reproducing** (P_{p−1} ⊂ +M_h) bi-orthogonal duals: the FE nodes must be **Gauss-Lobatto** spaced. +Equispaced Lagrange nodes (the default for tri-6, tet-10) give a +bi-orthogonal dual that loses one order of consistency; for quadratic +this is often invisible in practice but degrades for cubic+. See +[Oswald & Wohlmuth 2001]. + +### §4.9.4 Two relaxations: feasible and quasi-dual + +When the strict construction fails, two well-developed relaxations +recover bi-orthogonality on a *modified* basis: + +**Feasible dual basis** [Lamichhane & Wohlmuth 2007, §3]. +The LM space M_h has **the same dimension** as the trace space +W_{0,h}, and strict bi-orthogonality holds between {M_i} and a +*modified* primal basis {Ñ_j} obtained by local element-wise +re-coupling. Polynomial reproduction (P_p ⊂ M_h) is preserved by +construction. Support enlargement is bounded (≤ 2p+1 elements in 1D +patches). This is the construction behind the Popp et al. 2012 +basis-transformation procedure (§4.10). + +**Quasi-dual basis** [Lamichhane, Stevenson & Wohlmuth 2005, *Numer. +Math.* 102, doi:10.1007/s00211-005-0636-z]. The LM dimension is +*relaxed*: dim M_h < dim W_{0,h}, with strict bi-orthogonality holding +only on a smaller index set I_h^δ ⊂ I_h. The polynomial reproduction +condition is preserved, the mortar coupling matrix D remains diagonal +on the active LM block (so static condensation works), but the loss +of dimension matching means some primal modes are not directly +constrained — the construction relies on a continuous-mortar argument +to ensure the missing modes are controlled by the active ones. This is +the natural relaxation for cubic+ tetrahedra and serendipity hex where +even the feasible construction would require unmanageable support +enlargements. + +The user's project is well-served by the feasible variant for tri-6, +quad-8, quad-9; the quasi-dual is reserved for cubic+ tetrahedra (a +Phase-6+ scope item). + +## §4.10 The Popp-Wohlmuth-Gee-Wall basis-transformation procedure + +The most practical implementation of feasible higher-order dual bases — +used in BACI/4C, MOOSE, and the broader contact-mechanics literature — +is the **basis transformation** of [Popp, Wohlmuth, Gee & Wall 2012, +*SIAM J. Sci. Comput.* 34, B421–B446, doi:10.1137/110848190]. + +### §4.10.1 The recipe + +For each nonmortar-side element with FE shape vector N (size n_loc), define +a per-element transformation T_e ∈ ℝ^{n_loc × n_loc} such that +Ñ = T_e · N has positive lumped integral at every node: + + s̃_j = ∫_E Ñ_j dE > 0 for all j. (4.29) + +Then build the *feasible dual* on Ñ via the standard recipe (4.5): + + Ã_e = diag(s̃) · (M̃^FE)⁻¹ where M̃^FE_{ij} = ∫_E Ñ_i Ñ_j dE (4.30) + Φ_i = ∑_j Ã_{ij} Ñ_j (4.31) + +The full element-level transformation [Popp et al. 2012, eq. 37]: + + Φ = Ã_e · T_e · N = D̃_e · (T_e · M^FE · T_e^T)⁻¹ · T_e · N (4.32) + +This is "biorthogonal on Ñ but not on the original N" — which is what +*feasible* means. + +### §4.10.2 Explicit transformation matrices + +For each element type, Popp et al. 2012 specifies the transformation T_e +explicitly. The pattern is **redistribute mid-edge weight into the +adjacent corner nodes**, which in barycentric language is: + +For **tri-6** [Popp et al. 2012, eq. 38]: + + Ñ_i^corner = N_i^corner + ½ ∑_{k ∈ E(i)} N_k^edge (i = 1, 2, 3) + Ñ_k^edge = ½ N_k^edge (k = 4, 5, 6) + (4.33) + +where E(i) is the set of two edges adjacent to corner i. The +transformation matrix is then: + + T^tri6 = ⎡ 1 0 0 ½ 0 ½ ⎤ ← corner 1 absorbs ½ of edges 4,6 + ⎢ 0 1 0 ½ ½ 0 ⎥ ← corner 2 absorbs ½ of edges 4,5 + ⎢ 0 0 1 0 ½ ½ ⎥ ← corner 3 absorbs ½ of edges 5,6 + ⎢ 0 0 0 ½ 0 0 ⎥ ← edge 4 keeps ½ + ⎢ 0 0 0 0 ½ 0 ⎥ ← edge 5 keeps ½ + ⎣ 0 0 0 0 0 ½ ⎦ ← edge 6 keeps ½ (4.34) + +After applying (4.30)–(4.31), the resulting feasible dual coefficient +matrix on Ñ is [Popp et al. 2012, eq. 39]: + + Ã^tri6 = ⎡ 3 0 0 0 −½ −½ ⎤ + ⎢ 0 3 0 −½ 0 −½ ⎥ + ⎢ 0 0 3 −½ −½ 0 ⎥ + ⎢ 0 0 0 1 0 0 ⎥ (4.35) + ⎢ 0 0 0 0 1 0 ⎥ + ⎣ 0 0 0 0 0 1 ⎦ + +Row-sums = 1 (partition of unity preserved). Bi-orthogonality: +∫ Φ_i Ñ_j = δ_ij · s̃_j on the modified basis. P_1 reproduction holds +(sufficient for optimal H¹ rate on quadratic elements). + +For **quad-8 (serendipity)** [Popp et al. 2012, eq. 40], the pattern +is similar — each corner absorbs ¼ of each adjacent mid-edge — giving +the 8×8 transformation: + + Ã^quad8 = ⎡ 9/4 0 0 0 −¾ 0 0 −¾ ⎤ + ⎢ 0 9/4 0 0 −¾ −¾ 0 0 ⎥ + ⎢ 0 0 9/4 0 0 −¾ −¾ 0 ⎥ + ⎢ 0 0 0 9/4 0 0 −¾ −¾ ⎥ (4.36) + ⎢ 0 0 0 0 1 0 0 0 ⎥ + ⎢ 0 0 0 0 0 1 0 0 ⎥ + ⎢ 0 0 0 0 0 0 1 0 ⎥ + ⎣ 0 0 0 0 0 0 0 1 ⎦ + +The corner row coefficient 9/4 (vs 3 for tri-6) reflects the different +weight distribution; the −¾ couples each corner to its two adjacent +mid-edges. + +For **quad-9 (full Lagrangian)**, no transformation is required — the +dual basis is the strict tensor product (4.26) of the line-3 dual. + +For **hex-20** (serendipity), the construction parallels quad-8 with +each corner absorbing ¼ of each of the three adjacent mid-edges; the +explicit 20×20 matrix is in [Popp et al. 2012, eq. 41]. + +For **hex-27** (full Lagrangian), tensor product (4.27) — strict +bi-orthogonality. + +For **tet-10**, the dual basis lives on the tri-6 *face elements* of +the nonmortar-side surface, so the construction reduces to (4.34)–(4.35). + +### §4.10.3 The crosspoint / wirebasket modification at higher order + +The 1D Wohlmuth corner modification (§5.1) was "M_corner = 0, M_neighbor += 1 on the end element". The higher-order generalisation is *more +delicate* because there are multiple boundary-adjacent shape functions +per element (corner + edge-midnodes) and partition-of-unity must be +preserved with **polynomial reproduction up to P_{p−1}**, not just +constants [Lamichhane, Stevenson & Wohlmuth 2005, §3.2]. + +For each boundary node n on the wirebasket ∂γ, the modification picks +an interior triangle Δ̃ ⊂ E with vertices ℓ_1^n, ℓ_2^n, ℓ_3^n at distance +comparable to diam(Δ̃), and computes the **barycentric coordinates** +σ_r^n of n with respect to Δ̃ (the unique solution of +∑_r σ_r^n p(ℓ_r^n) = p(n) for all p ∈ P_1). The modification is then: + + M_{ℓ_r}^mod ← M_{ℓ_r} + σ_r^n · M_n, M_n^mod ← 0 (4.37) + +Naive copy-paste of the linear-case formula (assigning weight 1 to a +single neighbor) loses the P_1 reproduction and degrades to suboptimal +rates — the barycentric weighting (4.37) is essential. This generalises +the §5.1 line-2 recipe (where there's only one "neighbor" so its +barycentric weight is trivially 1). + +For **edge midnodes adjacent to face boundaries**, [Flemisch & Wohlmuth +2007] and [Popp et al. 2012, §3.3] specify an additional consistent +absorption: when an edge midnode lies on the wirebasket, its multiplier +weight folds into the *opposite* interior corner/edge node within the +same face element, with weights determined by the same P_{p−1} +reproduction condition. **Each element type / order combination +requires its own table of modifications**: the engineering literature +maintains explicit per-type code paths. + +### §4.10.4 Convergence rates + +For p-th order primal Lagrange FEs and the feasible dual mortar of +[Popp et al. 2012, Wohlmuth, Popp, Gee & Wall 2012, *Comput. Mech.* 49, +doi:10.1007/s00466-012-0704-z]: + +| Quantity | Rate | +|---|---| +| Energy norm ‖u − u_h‖_{H¹(Ω)} | O(h^p) | +| L² norm ‖u − u_h‖_{L²(Ω)} | O(h^{p+1}) | +| LM in (H^{1/2}_{00})' norm | O(h^p) | + +These match the standard mortar [Bernardi, Maday & Patera 1994] +rates — the dual relaxation costs no consistency. Quadrature must be +exact for at least degree 2p+1 to preserve the L² superconvergence; +segment-based integration (Puso-Laursen 2004) with 7-point Gauss on +triangles is standard for quadratic 3D contact. + +## §4.11 The lower-order projection (LOR) fallback + +For environments where implementing the §4.10 basis-transformation per +element type is too costly — and especially for the LLNL/MFEM +ecosystem, where this is the Tribol design choice — an attractive +alternative is to **build the constraint matrix at order 1 on a refined +boundary submesh**, leaving the volume problem at higher order. This is +the *lower-order refinement* (LOR) approach. + +### §4.11.1 The geometric setup + +Given a primal FE space V_h^{(p)} of order p ≥ 2 on a mesh T_h, the +**lower-order-refined boundary submesh** is constructed as follows: + +``` +function build_lor_boundary_submesh(pmesh, fes_p, periodic_attr): + # Step 1: extract boundary submesh of periodic faces. + psub = ParSubMesh.CreateFromBoundary(pmesh, periodic_attr) + + # Step 2: uniformly refine psub by p (= polynomial order of fes_p). + # After refinement, the vertices of psub_lor coincide *exactly* with + # the Lagrange nodes of order-p elements on the original boundary. + psub_lor = psub.UniformRefinement(times=log2(p)) # symbolic; use p sub-divisions + + # Step 3: build order-1 LM space on the refined submesh. + fec_lam = H1_FECollection(order=1, dim=psub_lor.Dimension()) + fes_lam = ParFiniteElementSpace(psub_lor, fec_lam, vdim=dim) + + return psub_lor, fes_lam +``` + +The crucial geometric property [Pazner & Kolev 2021, MFEM LOR docs]: + + {Lagrange nodes of P_p on T_h} = {vertices of T_{h/p} (uniform refine ×p)} + (4.38) + +For p = 2: a P2 line element has 3 nodes (corners + 1 midpoint), and +once-refined linear sub-elements have those same 3 vertices. A P2 quad +has 9 nodes (4 corners + 4 mid-edges + 1 centroid), and a 2×2-refined +quad has those same 9 vertices. A P2 hex has 27 nodes; a 2×2×2-refined +hex has those same 27 vertices. The Lagrange basis is *interpolatory* +at exactly the refinement vertices. + +Consequence: any continuous P_p field u_h on the original boundary +admits a unique continuous *piecewise-linear* representation u_h^{LOR} +on the refined boundary mesh, with **identical nodal values** — +u_h(x_α) = u_h^{LOR}(x_α) for every Lagrange node x_α. The mapping is a +trivial bijection of coefficient vectors. + +### §4.11.2 The constraint matrix on LOR + +With V_h^{(p)} restricted to the periodic boundary giving u_h on Γ⁻ +(the nonmortar side), and the LOR multiplier space Λ_h^{(1)} of order-1 +piecewise-linears on T_{h/p}, the mortar form (3.4) becomes: + + ⟨μ_i, [u_h ∘ Π − u_h]⟩_{Γ⁻} + = ∑_k (∫_{Γ⁻} μ_i (N_k^{+,(p)} ∘ Π) ds) u_k^+ + − ∑_j (∫_{Γ⁻} μ_i N_j^{−,(p)} ds) u_j^− + = 0 ∀ μ_i ∈ Λ_h^{(1)} (4.39) + +The integrals are computed *exactly* (or to high quadrature order) on +the LOR refined mesh, with μ_i piecewise linear and N_k^{(p)} piecewise +of order p. The element-level matrices D and A^m have the same form as +(3.5) but with mixed-order shape functions. + +The LM space is constructed using the **§4 linear dual basis** on the +refined LOR mesh — line-2, tri-3, or quad-4 dual depending on face +element type. **No higher-order dual derivation is needed.** The +linear bi-orthogonal dual on T_{h/p} satisfies (4.1) on each refined +sub-element: + + ∫_{E_{LOR}} M_i^{(1)} N_j^{(1),LOR} ds = δ_ij ∫_{E_{LOR}} N_j^{(1),LOR} ds + (4.40) + +where N_j^{(1),LOR} is the order-1 hat function on T_{h/p}. The +constraint matrix C is then assembled exactly as in §3, with the +nonmortar-side LM rows numbered by LOR-vertex and the displacement +columns numbered by P_p TDOFs of the original V_h^{(p)}. + +### §4.11.3 Stability and convergence under LOR + +The non-trivial point: pairing P_p displacement with P_1 multiplier +(the "p / 1" pairing) is **not automatically inf-sup stable**. +[Brivadis, Buffa, Wohlmuth & Wunderlich 2015, *CMAME* 284, +doi:10.1016/j.cma.2014.09.012]: "the p/(p−1) pairing is numerically +shown to be unstable" in the unmodified mortar formulation. The +instability manifests as cross-point oscillations in λ and a non-uniform +inf-sup constant, leading to suboptimal saddle-point errors: + + ‖u − u_h‖_{H¹} ≤ C · ε_primal + C · ε_LM + ≈ O(h^p) + O(h^{3/2}) (loses optimal rate at p ≥ 2) + (4.41) + +Three remediations exist in the literature, each with a different +trade-off: + +**(R1) Stay with p / (p−1) but apply Belgacem-style cross-point +modification.** Zero out vertex shape functions and redistribute via +barycentric weights (the §4.10.3 generalisation). This recovers +inf-sup stability for the strict p/(p−1) pairing but keeps the LM at +order p−1, which for p=2 gives a P1 LM — the same order as our LOR +choice. Belgacem mod is geometric on the original mesh; LOR is geometric +on the refined mesh. Algebraically related, distinct in practice. + +**(R2) Use the p / (p−2) pairing.** For elasticity p=2 this gives P2/P0 +constant LM, provably inf-sup stable but suboptimal in λ approximation. +Generally unsuitable for elasticity due to volumetric locking concerns. + +**(R3) Add a Barbosa-Hughes-type residual stabilisation term to the +saddle-point block.** [Acharya & Patel 2019, arXiv:1705.10519; +Gustafsson, Råback & Videman 2022, arXiv:2209.02418, +"Mortaring for linear elasticity using mixed and stabilised finite +elements"]. The stabilised mortar form replaces (3.3a)–(3.3b) with: + + a(u, v) − ⟨λ, [v]⟩ + γ_β ∑_E h_E ⟨λ − Π_h(E_b u), μ − Π_h(E_b v)⟩_E = ⟨f, v⟩ + (4.42a) + ⟨μ, [u]⟩ + γ_β ∑_E h_E ⟨…⟩ = 0 (4.42b) + +with a stabilisation parameter γ_β = O(1/(λ + 2μ)) (mesh-independent; +material-dependent), h_E the local element size, and Π_h(E_b ·) a +projection of the elasticity edge-flux. The added bilinear term gives +an additional "penalty-like" coupling that restores inf-sup stability +for *any* L²-conforming multiplier including P1 LM on P2 displacement. +**For RVE-PBC homogenisation, where the jump-error dominates the +quantities of interest (effective tangent moduli), route R3 is the most +pragmatic** — it adds one new integrator to the existing assembly +pipeline and recovers quasi-optimal convergence. + +For the LOR pairing in particular, the LOR refinement *also* improves +the inf-sup constant by reducing the "LM space too coarse" effect: the +LM on T_{h/p} has more DOFs than the LM on T_h would have at the same +order. For p=2 the LOR LM has the *same* DOF count as a P_2 LM on T_h +— LOR is "P1 on a refined mesh" not "P1 on the original". The cross- +point issue is genuinely there but is locally bounded; published +homogenisation studies report effective tangent moduli converging at +the bulk rate even with mismatched-order LM, provided the saddle point +is well-posed (i.e. the cross-point modification or stabilisation is +in place). + +### §4.11.4 The MFEM mechanics + +A single ParMesh can carry both a P2 displacement FES and a P1 LM FES on +a refined ParSubMesh — polynomial order is a property of the FES, not +the Mesh [MFEM `fem/fe_coll.hpp`]: + +```cpp +// Volume FES at order 2. +auto *fec_u = new H1_FECollection(2, dim); +auto *fes_u = new ParFiniteElementSpace(&pmesh, fec_u, dim, + Ordering::byVDIM); + +// LOR boundary submesh + order-1 LM FES. +ParSubMesh psub = ParSubMesh::CreateFromBoundary(pmesh, periodic_bdr_attr); +psub.UniformRefinement(); // refine once for p=2; twice for p=3 (= p subdivisions) +auto *fec_lam = new H1_FECollection(1, psub.Dimension()); +auto *fes_lam = new ParFiniteElementSpace(&psub, fec_lam, dim); + +// Mixed-order constraint matrix. +ParMixedBilinearForm Cmat(fes_u, fes_lam); +Cmat.AddTraceFaceIntegrator(new MortarConstraintIntegrator(M_line2_dual)); +Cmat.Assemble(); +``` + +The crucial properties: + +- `H1_Trace_FECollection` is **not** required — ParSubMesh handles the + trace geometry directly. +- The constraint matrix C is built with `ParMixedBilinearForm` whose + trial space is the high-order displacement FES and test space is the + low-order LM FES on the refined submesh. Quadrature rule is selected + for the higher of the two orders. +- **Partial / element / full assembly is per-bilinear-form**. Keep K at + PA on GPU; assemble C at FULL (sparse HypreParMatrix). The block + saddle-point operator `[[K_op, Cᵀ_op], [C_op, 0]]` mixes a matrix-free + K with a sparse C — exactly the abstraction the §6 prototype already + uses. **Constraint construction remains agnostic to the volume + assembly choice (PA / EA / FA)**, as designed. +- AMG on K under PA requires `ParLORDiscretization` for the AMG + setup; this is a separate concern from LOR mortar and orthogonal to + the constraint design. + +### §4.11.5 Implementation cost vs higher-order dual + +| Approach | Engineering cost | Per element-type proliferation | MFEM availability | +|---|---|---|---| +| Higher-order standard P_p LM with Belgacem cross-point modification | Medium | Low (vertex zero-out + barycentric redistribution) | Doable with stock APIs | +| Higher-order **dual** (Popp 2012 basis transformation) | **High** | **Per element type**: tri-6, quad-8, quad-9, hex-20, hex-27 each need own A_e and own boundary modifications | Not in stock MFEM; requires custom FECollections + integrators | +| **LOR + linear dual + Barbosa-Hughes stabilisation** (recommended) | **Low** | None (re-uses §4.2–§4.5 linear dual) | Out-of-the-box with one extra integrator | +| Tribol-style LOR projection | Low | None | Available in MFEM 4.7+ via Tribol miniapp | +| Penalty (no LM) | Trivial | None | Trivial; conditioning issues | + +## §4.12 Recommendation for ExaConstit higher-order PBC + +ExaConstit's primary FE order for crystal plasticity is p = 1 (linear +hex / linear tet); higher-order is **not** on the immediate roadmap. +However, when it eventually is, the recommended path is: + +1. **Stay with the current §4.2–§4.5 linear dual basis machinery.** +2. **Build an order-1 LM space on a uniformly-refined ParSubMesh** of + the periodic boundary, per (4.38) and the §4.11.4 mechanics. +3. **Add a Barbosa-Hughes residual stabilisation integrator** (4.42) + to the saddle-point block; γ_β tuned per material. +4. **Validate with manufactured-solution h-refinement** to confirm + near-optimal H¹ rates O(h^p) on the displacement. +5. **Reach for the §4.10 Popp 2012 basis-transformation only if a + homogenisation use case demonstrates measurable accuracy degradation + at the engineering quantities of interest** (effective tangent + moduli, stress homogenisation). Existing CPFEM-homogenisation + literature has *no* precedent for higher-order mortar PBC and + suggests this is unlikely to be needed. + +This recommendation aligns with Tribol's design philosophy +[Chin, MFEM Workshop 2023, "Contact constraint enforcement using the +Tribol interface"] and avoids the proliferation of per-element-type +dual basis derivations and Wohlmuth modifications. The +**assembly-agnostic constraint construction** that has been a design +invariant since Phase 1A is preserved: C is a sparse HypreParMatrix +built from linear duals, K is consumed via Operator interface at any +PA/EA/FA setting, and the saddle-point solver in §6 doesn't care. + +We flag higher-order extensions as a Phase-6+ scope item in §14.3. + +--- + +# §5. Hierarchical crosspoint structure and the Wohlmuth modification + +The crosspoint problem arises because the standard dual basis (§4) places +nonzero multiplier weight at *every* nonmortar-side node, including those that +are essentially constrained (corners) or already constrained at a lower +hierarchy level (edges in 3D). The constraint becomes redundant or +inconsistent. **Wohlmuth's modification** [Wohlmuth 2000, §5; +Wohlmuth 2001, §1.3.4] adjusts the dual basis on nonmortar-side elements +adjacent to such crosspoints so that: + +1. The multiplier rows for "redundant" DOFs are removed (M_redundant ≡ 0 + on the affected element). +2. **Partition of unity** (§4.0, eq. 4.6) is preserved on the modified + element, ensuring constant-reproduction across the interface. +3. **Local biorthogonality is relaxed in a controlled way**: the modified + M_i is no longer pointwise dual to N_j on the modified element, but the + *quasi-dual* property [Lamichhane & Wohlmuth 2007, §3.2] holds — the + constraint enforces the right physics in the modified region. + +This section derives the modification explicitly for line-2 (used in 2D +edge mortar and 3D edge mortar), tri-3 (used in 3D face mortar on tet +meshes), and quad-4 (used in 3D face mortar on hex meshes). The 1D case +is the foundation; the 2D cases generalize it to tensor-product (quad) +and barycentric (triangle) settings. + +## §5.1 The 2D problem and the line-2 modification + +Take a square RVE with the 4 corners and 4 edges. The PBC story: + +- **Corners**: pin all 4 corners to remove rigid-body translation and + rotation. 4 corners × 2 components = 8 essential TDOFs. In Method D, + corner *displacement values* are u_lin[corner] = (F − I) X_corner; in + Method C they are zero (essential ũ at corners). Reference: [Lopes + et al. 2021, §3.4, lines 1034–1035]. +- **Edges**: couple opposite-edge pairs (right ↔ left, top ↔ bottom) via + the line-2 mortar method (§3, §4.2). Each edge has interior nodes plus + two end nodes. The end nodes ARE the corners — they overlap with the + essential set. + +### §5.1.1 The crosspoint over-constraint + +Without modification, the nonmortar-side line-2 mortar would assemble an LM +row for *every* nonmortar DOF, including the corner DOFs at the edge endpoints. +Combined with the corner essential BC, this produces: + +| DOF | Essential BC | Mortar LM row | Result | +|---|---|---|---| +| Corner | u = u_lin[corner] | row in C with corner column nonzero | over-constrained | +| Edge interior | none | row in C with column nonzero | correctly constrained | + +The "over-constraint" comes through: the constraint matrix C now has rows +that mention the essential corner DOFs in their column structure. After +applying corner Dirichlet (which zeroes those columns of C — see +`apply_dirichlet_zero_to_C`), the LM rows for the corner DOFs become +*zero rows*: 0 = 0 trivially, but they consume LM unknowns. The system +has redundant constraints; the C·diag(K)⁻¹·Cᵀ Schur complement has a zero +diagonal entry corresponding to the corner-LM row, which makes the +saddle-point preconditioner ill-defined. + +### §5.1.2 The modification: M_i on the corner-end element + +Let the nonmortar-side end element be a line-2 with nodes labeled 1 (the corner +endpoint, ξ = −1) and 2 (the interior neighbor, ξ = +1). The +*standard* dual basis (eq. 4.13): + + M_1(ξ) = (1 − 3ξ) / 2 (corner side) (5.1a) + M_2(ξ) = (1 + 3ξ) / 2 (neighbor side) (5.1b) + +The Wohlmuth-modified dual basis on this end element [Wohlmuth 2000, §5; +Lopes et al. 2021, Eq. C.2]: + + M_1^mod(ξ) ≡ 0 (corner row dropped) (5.2a) + M_2^mod(ξ) ≡ 1 (neighbor takes constant value) (5.2b) + +This says: on the corner-end element, do not assemble a constraint row for +the corner DOF. The neighbor DOF's multiplier is identically 1 — a +*constant* over this element. + +**Partition of unity preserved.** M_1^mod(ξ) + M_2^mod(ξ) = 0 + 1 = 1 +for all ξ ∈ [−1, +1]. ✓ + +**Constant reproduction preserved.** A constant ũ ≡ c integrated against +M_2^mod on this element gives ∫ M_2^mod · c dξ = c · 2 (segment length on +[−1,+1]), which is the same value the standard linear-N integration would +give: ∫ N_1 c + ∫ N_2 c = c · 1 + c · 1 = 2c. So the modified basis +reproduces constants correctly across the modified end-segment. + +**Biorthogonality is relaxed.** ∫ M_2^mod N_2 dξ = ∫ 1 · (1+ξ)/2 dξ = 1 +(matches the standard target ∫ N_2 = 1). But ∫ M_2^mod N_1 dξ = ∫ 1 · +(1−ξ)/2 dξ = 1 ≠ 0. The off-diagonal "leak" is intentional: it routes the +corner-DOF coupling into the neighbor's row, which is what removes the +redundancy with the corner Dirichlet [Wohlmuth 2000, eq. 5.4]. + +### §5.1.3 Why this fixes the over-constraint + +After modification: + +- The **corner LM row is gone** (M_corner^mod = 0 means no constraint + contribution from this element to the corner row, and dropping the + corner row entirely from the LM space removes the redundancy). +- The **neighbor LM row** still constrains the neighbor DOF, but now + through M_2^mod = 1, which integrates against both N_1 and N_2 on the + end element. + +The constraint then enforces the right physics: the neighbor's +fluctuation periodicity, while letting the corner be free to satisfy its +Dirichlet BC without LM interference. + +The implementation in `mortar_pbc/mortar_2d.py`: + +```python +def M_line2_dual_modified(xi: float, side: str) -> tuple[float, float]: + """Lopes Eq. C.2 / Wohlmuth (2000) corner-modified dual basis. + + side == 'left' : the left node (ξ=-1, "node 1") is the Dirichlet corner. + M_1 = 0; M_2 = 1. + side == 'right' : the right node (ξ=+1, "node 2") is the Dirichlet corner. + M_1 = 1; M_2 = 0. + side == 'none' : interior element, use standard dual basis. + """ + if side == "left": + return (0.0, 1.0) + elif side == "right": + return (1.0, 0.0) + else: + return M_line2_dual(xi) +``` + +Verified by `test_wohlmuth_crosspoint_modification` (partition of unity, +corner-side-zero, neighbor-side-integrals). + +## §5.2 The triangle (tri-3) modification (3D face mortar on tet meshes) + +For a tet-mesh RVE, periodic faces are tri-3 elements. The face boundary +has *three edges* and *three corners*. The Wohlmuth modification on a +triangle adjacent to a face-boundary edge (or corner) generalises the 1D +recipe. + +### §5.2.1 Triangle classification by face-boundary adjacency + +Let a tri-3 face element have vertices labeled 1, 2, 3 with barycentric +coordinates (1,0,0), (0,1,0), (0,0,1). The face boundary is a 2D loop; +each tri-3 face element belongs to one of: + +- **Interior** — none of the 3 vertices is on the face boundary. + Standard dual basis (eq. 4.19): M_i = 4 λ_i − 1. +- **Edge-adjacent** — exactly one vertex is on the face boundary, OR + one whole edge of the triangle lies on the face boundary. Modify + the dual basis at that vertex/edge. +- **Corner-adjacent** — two vertices are on face-boundary edges (i.e., + the triangle touches a face *corner*). Modify two vertices. + +(A tri-3 face element cannot have *all three* vertices on the face +boundary unless the tri-3 *is* a face corner triangle, which is a +degenerate case for a coarse mesh — possible but rare. We handle it as +the degenerate limit of the corner-adjacent case.) + +### §5.2.2 Edge-adjacent modification (one vertex dropped) + +Suppose vertex 1 (with shape function N_1 = λ_1) is on a face-boundary +edge. The modified dual basis sets M_1^mod = 0 and re-distributes the +weight across M_2 and M_3: + + M_1^mod(λ) = 0 (5.3a) + M_2^mod(λ) = a + b λ_2 + c λ_3 (5.3b) + M_3^mod(λ) = a + c λ_2 + b λ_3 (by symmetry) (5.3c) + +We require partition of unity: M_2^mod + M_3^mod = 1, i.e. + + 2a + (b+c)(λ_2 + λ_3) = 1 for all (λ_2, λ_3) with λ_1 = 1 − λ_2 − λ_3 + +This must hold for all admissible (λ_2, λ_3), so: +- coefficient of (λ_2 + λ_3): b + c = 0 → c = −b +- constant term: 2a = 1 → a = 1/2 + +We additionally require the standard target integrals: + + ∫_E M_2^mod N_2 dE = ∫_E N_2 dE = |E|/3 (5.4) + +Computing with (5.3b) and (4.7): + + ∫_E (1/2 + b λ_2 − b λ_3) λ_2 dE + = (1/2) ∫ λ_2 dE + b ∫ λ_2² dE − b ∫ λ_2 λ_3 dE + = (1/2)(|E|/3) + b(|E|/6) − b(|E|/12) + = |E|/6 + b|E|/12 + +Set equal to |E|/3 = 4|E|/12: + + |E|/6 + b|E|/12 = 4|E|/12 + 2|E|/12 + b|E|/12 = 4|E|/12 + b = 2 + +So: + + M_2^mod(λ) = 1/2 + 2 λ_2 − 2 λ_3 (5.5a) + M_3^mod(λ) = 1/2 − 2 λ_2 + 2 λ_3 (5.5b) + M_1^mod(λ) = 0 (5.5c) + +**Verification.** Partition of unity: +M_2 + M_3 = 1 + 0 + 0 = 1. (M_1 = 0 contributes nothing.) +Including the dropped corner: M_1 + M_2 + M_3 = 0 + 1 = 1. ✓ + +Bi-orthogonality (target value): +- ∫ M_2 N_2 = (1/2)(|E|/3) + 2(|E|/6) − 2(|E|/12) = |E|/6 + |E|/3 − |E|/6 = |E|/3 ✓ +- ∫ M_2 N_3 = (1/2)(|E|/3) + 2(|E|/12) − 2(|E|/6) = |E|/6 + |E|/6 − |E|/3 = 0 ✓ +- ∫ M_2 N_1 (the *dropped* row's column): (1/2)(|E|/3) + 2(|E|/12) − 2(|E|/12) = |E|/6 ≠ 0 + +The last entry is the "leak" — a controlled non-orthogonality between the +modified M_2 and the dropped node's N_1, identical in spirit to the 1D +case (§5.1.2). The corner DOF is essentially constrained, so the leak +into N_1's column is harmless after corner-column zeroing of C. + +### §5.2.3 Corner-adjacent modification (two vertices dropped) + +Suppose vertices 1 and 2 are both on face-boundary edges (so the tri-3 +touches a face corner where two boundary edges meet). The modification +sets both M_1^mod = M_2^mod = 0, and the third vertex's M_3^mod must +satisfy the partition-of-unity and constant-reproduction targets alone. + +By symmetry of the construction, M_3^mod(λ) = a + b λ_3. Partition of +unity (only M_3^mod is nonzero among the three): + + M_3^mod(λ) = 1 ∀ λ ∈ E (i.e. a = 1, b = 0) (5.6) + +This is the direct 2D analog of (5.2): on a corner-adjacent triangle, the +single non-dropped multiplier is identically 1. + +**Verification.** + +- Partition of unity: 0 + 0 + 1 = 1 ✓ +- Constant reproduction: ∫ 1 · c dE = c · |E|, matches ∫(N_1+N_2+N_3) c dE + = ∫ 1 · c dE = c · |E| ✓ +- ∫ M_3 N_3 = ∫ 1 · λ_3 dE = |E|/3 = ∫ N_3 ✓ (target met) +- ∫ M_3 N_1 = ∫ 1 · λ_1 dE = |E|/3 ≠ 0 (leak, harmless after corner-col zero) +- ∫ M_3 N_2 = |E|/3 (leak) + +### §5.2.4 Implementation outline (Phase 3.2) + +```python +def M_tri3_dual_modified( + lam: tuple[float, float, float], + boundary_nodes: tuple[bool, bool, bool], +) -> tuple[float, float, float]: + """Wohlmuth-modified dual basis on a tri-3 face element. + + boundary_nodes[i] = True if vertex i is on a face-boundary feature + (edge or corner of the parent face) and therefore + the corresponding LM row should be dropped. + + Cases: + 0 boundary nodes: standard tri-3 dual (M_i = 4 λ_i − 1). + 1 boundary node: edge-adjacent modification (eq. 5.5). + 2 boundary nodes: corner-adjacent modification (eq. 5.6 — the + remaining vertex's multiplier is identically 1). + 3 boundary nodes: degenerate; multiplier identically 0 on this + element (no constraint contribution). + """ + n_dropped = sum(boundary_nodes) + if n_dropped == 0: + return M_tri3_dual(lam) + elif n_dropped == 1: + # Identify which vertex is dropped, apply (5.5) accordingly. + idx_dropped = boundary_nodes.index(True) + # ... permute (5.5) so that the dropped vertex gets M = 0 + ... + elif n_dropped == 2: + # Identify which vertex is *not* dropped; its M = 1, others = 0. + idx_kept = boundary_nodes.index(False) + result = [0.0, 0.0, 0.0] + result[idx_kept] = 1.0 + return tuple(result) + else: # n_dropped == 3 + return (0.0, 0.0, 0.0) +``` + +Verification target for Phase 3.2 unit test +`test_wohlmuth_tri3_modification`: + +- Bi-orthogonality at non-dropped vertices: ∫ M_i^mod N_i = ∫ N_i = |E|/3. +- Off-diagonal between two non-dropped vertices: 0. +- Partition of unity over non-dropped vertices: 1. +- Off-diagonal into dropped vertices: |E|/3 (harmless leak). + +## §5.3 The quad-4 modification (3D face mortar on hex meshes) + +For a hex-mesh RVE, periodic faces are quad-4 elements. The face boundary +has *four edges* and *four corners*. The Wohlmuth modification generalises +the 1D recipe via tensor product. + +### §5.3.1 Quad classification + +Let a quad-4 face element have nodes labeled 1, 2, 3, 4 at parametric +corners (−1,−1), (+1,−1), (+1,+1), (−1,+1). Each face element is one of: + +- **Interior** — none of the 4 vertices is on the face boundary. + Standard quad-4 dual basis (eq. 4.16). +- **Edge-adjacent** — exactly one edge of the quad-4 (so 2 of its 4 + vertices) is on a face-boundary edge. Modify the dual basis in *one* + parametric direction. +- **Corner-adjacent** — exactly one vertex is on a face corner (and 2 of + its 4 vertices are on face-boundary edges). Modify in *both* + parametric directions. + +### §5.3.2 Edge-adjacent: one parametric direction modified + +Suppose the η = −1 edge of the quad-4 is on a face-boundary edge. Then +nodes 1 and 2 (η-coordinate = −1) are dropped; nodes 3 and 4 (η-coordinate += +1) are kept. + +The 1D modified dual basis in η (with side="left", since η = −1 is the +"left" of [−1,+1]): + + M_line2_mod(η, "left") = (0, 1) (M(η=-1)=0, M(η=+1)=1) (5.7) + +Tensor product with the standard 1D dual in ξ: + + M_quad4_1^mod(ξ,η) = M_line2(ξ, p=1) · 0 = 0 (5.8a) + M_quad4_2^mod(ξ,η) = M_line2(ξ, p=2) · 0 = 0 (5.8b) + M_quad4_3^mod(ξ,η) = M_line2(ξ, p=2) · 1 = (1+3ξ)/2 (5.8c) + M_quad4_4^mod(ξ,η) = M_line2(ξ, p=1) · 1 = (1−3ξ)/2 (5.8d) + +So nodes 1 and 2 (the dropped edge) have M ≡ 0; nodes 3 and 4 (the +neighboring edge) have M = 1D-dual-in-ξ × 1. + +Partition of unity in (ξ, η) on this element: + + ∑_i M_i^mod = 0 + 0 + (1+3ξ)/2 + (1−3ξ)/2 = 1 ∀ (ξ,η) (5.9) + +✓ The 1D partition-of-unity in ξ carries through. + +Symmetric for the other three boundary-edge orientations (η=+1, ξ=±1). + +### §5.3.3 Corner-adjacent: both parametric directions modified + +Suppose node 1 (parametric corner (−1,−1)) is on a face corner. Then both +the ξ = −1 edge AND the η = −1 edge of the quad-4 are face-boundary +edges. The 1D modification applies in *both* ξ and η directions, giving +(side_ξ, side_η) = ("left", "left"): + + M_line2_mod(ξ, "left") = (0, 1) + M_line2_mod(η, "left") = (0, 1) + +Tensor product: + + M_quad4_1^mod(ξ,η) = 0 · 0 = 0 (the corner) (5.10a) + M_quad4_2^mod(ξ,η) = 1 · 0 = 0 (corner-adjacent in η) (5.10b) + M_quad4_3^mod(ξ,η) = 1 · 1 = 1 (diagonally opposite) (5.10c) + M_quad4_4^mod(ξ,η) = 0 · 1 = 0 (corner-adjacent in ξ) (5.10d) + +Only the **diagonally opposite** vertex has a non-zero (and constant) +multiplier on this corner-adjacent quad. Partition of unity: 0 + 0 + 1 + +0 = 1 ✓. + +This is the direct 2D analog of (5.6) — same structure as the +corner-adjacent triangle case, where the single non-dropped multiplier is +identically 1. + +### §5.3.4 Implementation outline (Phase 3.2) + +```python +def M_quad4_dual_modified( + xi: float, eta: float, + side_xi: str = "none", # "none" | "left" | "right" + side_eta: str = "none", # "none" | "bottom" | "top" +) -> tuple[float, float, float, float]: + """Wohlmuth-modified dual basis on a quad-4 face element via tensor product. + + side_xi modification: "left" drops node-side ξ=-1; "right" drops ξ=+1. + side_eta modification: "bottom" drops node-side η=-1; "top" drops η=+1. + + Edge-adjacent: exactly one of (side_xi, side_eta) is non-"none". + Corner-adjacent: both are non-"none" (diagonal-opposite node retains M=1). + """ + M_xi = M_line2_dual_modified(xi, side_xi) # tuple of 2 + M_eta = M_line2_dual_modified(eta, side_eta) # tuple of 2 + return ( + M_xi[0] * M_eta[0], # node 1 at (-1,-1) + M_xi[1] * M_eta[0], # node 2 at (+1,-1) + M_xi[1] * M_eta[1], # node 3 at (+1,+1) + M_xi[0] * M_eta[1], # node 4 at (-1,+1) + ) +``` + +Verification target for Phase 3.2 unit test +`test_wohlmuth_quad4_modification`: + +- Edge-adjacent: nodes on the modified edge have M ≡ 0; partition of + unity preserved. +- Corner-adjacent: only the diagonal-opposite node has M ≡ 1; partition + of unity preserved. +- Bi-orthogonality (target): ∫ M_i^mod N_i = ∫ N_i (|E|/4 for the 4-node + quad with the standard mass-integral target). + +### §5.3.5 The 3-sentinel corner-of-face quad (subtle but ubiquitous) + +When the boundary classifier (§11.8 Phase 3.3.B) walks face elements +and stamps sentinel values on per-vertex DOFs, a single quad-4 +element can carry **three** sentinels at once: one corner-of-the-RVE +DOF (sentinel `-1`) plus two box-edge-interior DOFs (sentinel `-2`) +on the two element edges meeting at that RVE corner. The remaining +fourth node — diagonally opposite the RVE corner — is the only kept +face-interior DOF. + +This 3-sentinel pattern is **the most common boundary-adjacent quad +configuration on an axis-aligned RVE**: every box face has 4 such +quads at its 4 corners. On a 4×4×4 hex mesh, that's 24 such quads +(4 per face × 6 faces). They are NOT degenerate cases — they're +the bulk of the wirebasket-modified work. + +The right Wohlmuth tag for this configuration is one of `corner-LL`, +`corner-LR`, `corner-UR`, `corner-UL`, picked so the dropped sides +match the {ξ, η} extents of the sentinel cluster. The naming +convention is **side-coverage, not corner-of-kept-node**: the tag +names which two element sides are dropped, NOT which corner the +kept node is at. Mapping (where the kept node is the only +non-sentinel local node): + +| kept local node | kept-node corner | dropped sides | tag | +|---|---|---|---| +| 0 | (xi=−1, eta=−1) "LL" | xi-high + eta-high | `corner-UR` | +| 1 | (xi=+1, eta=−1) "LR" | xi-low + eta-high | `corner-UL` | +| 2 | (xi=+1, eta=+1) "UR" | xi-low + eta-low | `corner-LL` | +| 3 | (xi=−1, eta=+1) "UL" | xi-high + eta-low | `corner-LR` | + +(Yes, the tag for "kept node 2 = UR corner" is `corner-LL` — +because side_xi="left" and side_eta="bottom" are what's dropped. +The tag is named after the dropped sides; this is the convention +used by `M_quad4_dual_modified(side_xi="left", side_eta="bottom")`.) + +**Why the modification matters for correctness here.** If the +3-sentinel quad were tagged `'none'` and the assembler used the +standard (unmodified) dual basis for the kept row, the constraint +matrix would *almost* be right: the constraint builder zeros the +corner/edge columns by sentinel logic anyway. But the kept (face- +interior, face-interior) entry of A_m would carry a small leak +from the standard-vs-modified dual basis difference. That leak +manifests as a small constraint residual at convergence (not a +catastrophic failure, but a real correctness issue). The modified +dual basis fixes the kept-row entries to the right values. The +fix is implemented in +``BoundaryClassifier3D._classify_quad_boundary_tag`` which dispatches +all 16 sentinel-pattern cases (0/1/2/3/4 sentinels with all +geometric arrangements). + +The analogous 2-vertex-dropped tri-3 case (§5.2.3) handles the +corresponding tet-mesh configuration cleanly — the +``M_tri3_dual_modified`` machinery accepts `boundary_nodes = (T, T, F)` +to drop two vertices simultaneously, with the kept vertex's dual +becoming a constant 1 (per eq. 5.6). + +## §5.4 The 3D wirebasket hierarchy + +In 3D the geometric hierarchy is one level deeper than 2D: + +| Feature | Dim | Count (cube RVE) | Constraint role | LM rows | +|---|---|---|---|---| +| **Corner** | 0 | 8 | Essential Dirichlet (u_corner = (F−I)X_corner) | None | +| **Edge** (wirebasket) | 1 | 12 | Mortar, with 1D Wohlmuth at corner endpoints | Corners dropped | +| **Face** | 2 | 6 | Mortar, with 2D Wohlmuth (tri or quad) along edge boundary | Edges dropped | + +The cascade ensures non-redundancy: each level constrains exactly the +DOFs that aren't already covered by a higher level [Wohlmuth 2001, +§1.3.4; Lamichhane & Wohlmuth 2007, §3.3]. + +Three levels of constraint, three modifications: + +1. **Corner Dirichlet**: 24 essential TDOFs (8 corners × 3 components). + Method D applies u_corner = (F − I) X_corner; the 8 corners are pinned + exactly. No LM rows. +2. **Edge mortar with corner crosspoint mod**: each pair of periodic + edges gets one mortar block. Wohlmuth modification at corner + endpoints (eq. 5.2) removes corner-LM rows. The cube has 12 edges + total, partitioned into 3 groups of 4 (by axis parallelism); within + each group, pick one as mortar and assemble 3 mortar-nonmortar mortar + blocks. Total: 3 directions × 3 = 9 edge mortar blocks. +3. **Face mortar with edge crosspoint mod**: each pair of opposite faces + gets one mortar block. Wohlmuth modification along edge boundaries + (eq. 5.5 / 5.6 for triangles, eq. 5.8 / 5.10 for quads) removes + edge-LM rows. There are 3 face pairs (one per axis direction). + +## §5.5 Hex meshes vs tet meshes: same hierarchy, different elements + +The hierarchy in §5.4 is independent of element type. What differs is +the *element class* used at each level: + +| Mesh type | Volume element | Face element | Edge element | +|---|---|---|---| +| **Hex** | hex-8 | quad-4 | line-2 | +| **Tet** | tet-4 | tri-3 | line-2 | +| **Mixed** | hex-8 + tet-4 | quad-4 + tri-3 | line-2 | + +In all three cases: + +- Edge mortar uses the **line-2** dual basis with the 1D Wohlmuth + modification (§5.1). The element class is the same regardless of + whether the parent volume is hex or tet. +- Face mortar uses **quad-4** (hex parent) or **tri-3** (tet parent), + with the corresponding 2D Wohlmuth modification (§5.2 for tri-3, §5.3 + for quad-4). +- Mixed meshes: each face dispatches on its element type. A + quad-4-face from a hex element next to a tri-3-face from a tet + element on the same periodic boundary is allowed; the constraint + rows assemble per-face with the appropriate `M_*_dual_modified` + function. + +The architectural implication: the C++ port must dispatch on +`mfem::Element::Type` (or equivalent) when assembling face mortar, +selecting the dual basis polymorphically. This polymorphism slots +naturally into a `MortarFaceAssembler` class with virtual `Assemble` +implementations for `QuadFaceAssembler` and `TriFaceAssembler`. + +ExaConstit currently supports both hex and tet meshes for crystal +plasticity, with users routinely choosing between them based on grain +geometry complexity. PBC support must therefore handle both natively +[ExaConstit issue #8 commentary; ExaConstit user guide §3]. + +## §5.6 Why this matters for correctness + +If you skip the Wohlmuth modification: + +- **2D**: the patch test still passes for some macroscopic F (e.g. + uniform uniaxial), but fails for shear F or any F that places the + corner-LM redundancy into a numerical contradiction. The discrete + constraint becomes inconsistent at the corner; the saddle-point + Schur complement has zero diagonal entries; the block-Jacobi + preconditioner produces NaN or infinite scalers. +- **3D**: the situation is worse. Without the edge-level modification, + every face mortar is over-constrained at all 12 edges. Without the + corner-level modification on edges, every edge mortar is + over-constrained at all 8 corners. The redundant constraints don't + just produce slightly-wrong answers; they produce a singular + C·diag(K)⁻¹·Cᵀ Schur complement. + +So the modification is not optional [Wohlmuth 2000, Theorem 5.1]. The +unit tests verify the modification *at the dual-basis level* +(independent of the FE assembly), making the correctness easy to +localise when something downstream breaks. + +The 2D unit test `test_wohlmuth_crosspoint_modification` validates +properties (5.2). Phase 3.2 will add `test_wohlmuth_tri3_modification` +(eqs. 5.5, 5.6) and `test_wohlmuth_quad4_modification` (eqs. 5.8, 5.10) +as 3D analogs. + +--- + +# §6. The saddle-point system and how we solve it + +## §6.1 The continuous problem + +For Method D with linear elasticity (the prototype's solving regime), the +strong form is: + +- ∇·σ = 0 in Ω +- σ = C·ε, ε = (∇u + ∇uᵀ)/2 (linear elastic) +- u = u_lin = (F−I)X on essential corner set +- ⟨ũ⟩-periodic on opposite faces (mortar weak periodicity) + +Lagrangian for the constrained equilibrium: + +L(u, λ) = (1/2) uᵀ K u − λᵀ C u + +(no body force in our setup; the corner displacement enters as a Dirichlet +BC, not via L). + +Stationary: K u + Cᵀ λ = 0; C u = 0. + +The discretized form is: + +[[K, Cᵀ], [C, 0]] [u; λ] = [b; 0] + +where b absorbs whatever right-hand side comes from the corner Dirichlet +elimination (it's K_eliminated u_lin shifted to the RHS, with corner entries +forced to satisfy u = u_lin[corner]). + +## §6.2 Indefiniteness — why CG is rejected + +The saddle-point matrix has signature (+, −) — symmetric but not positive +definite. CG diverges (or worse, gives garbage). Three valid Krylov choices: + +- **MINRES**: optimal for symmetric indefinite. Default for our linear-elastic + symmetric K. +- **GMRES**: works for any matrix; needed when K is non-symmetric (some + constitutive models give non-symmetric tangent — crystal plasticity + *can*). +- **BiCGStab**: a non-symmetric option with shorter recurrences than GMRES. + +The `SaddlePointSolver` class supports all three at runtime via a +`solver=` parameter. CG is explicitly forbidden in the API. + +## §6.3 The block-Jacobi preconditioner + +The 2-block diagonal preconditioner: + +P = [diag(K), 0; 0, diag(C diag(K)⁻¹ Cᵀ)] + +implemented as: + +- Block (0,0): apply diag(K)⁻¹. Computed via `Operator.AssembleDiagonal()`, + which works uniformly on PA, EA, FA, and HypreParMatrix forms of K. We + *never* call `K.As()` or anything like that — diagonal + extraction is the right level of abstraction. +- Block (1,1): apply diag(C diag(K)⁻¹ Cᵀ)⁻¹. Computed *without* forming + C diag(K)⁻¹ Cᵀ explicitly — instead the C operator exposes a method + `WeightedRowSqSum(weights, out)` that returns out[i] = Σ_j C[i,j]² · w[j] + for owned rows. With w = diag(K)⁻¹ this gives exactly the row-diagonal of + C diag(K)⁻¹ Cᵀ, the missing piece. + +In production we'll replace block-Jacobi-on-K with HypreBoomerAMG (when K is +fully assembled) or a multigrid-on-PA-K (when K is matrix-free). The +prototype's block-Jacobi is a stepping stone. + +## §6.4 The RHS construction (the bug-prone part) + +Given the linear system: + +[[K_e, Cᵀ], [C, 0]] [du, dλ] = [−r1, 0] + +where: + +- K_e = K with corner rows/cols zeroed and replaced by identity-on-diagonal. +- r1 = K_full · u_lin (the full, un-eliminated K applied to u_lin), with + corner entries of r1 zeroed afterward. + +**Why r1 must use K_full and not K_e:** + +For homogeneous material under uniform F, the affine field u_lin IS the +equilibrium solution. That means K_full · u_lin = 0 at *free* rows +(Σ_col K_full[free_row, col] · u_lin[col] = 0). At corner rows it gives the +nontrivial corner reaction force, but those rows of r1 are zeroed. + +If instead you compute r1 = K_e · u_lin, the K_uc column has been zeroed by +the elimination, so K_e · u_lin at free rows gives K_uu · u_lin[free] only — +which is *NOT* zero in general (the affine field requires the K_uc · u_lin[corner] +contribution to balance K_uu · u_lin[free] for the affine to be the solution). +The result is r1 has spurious nonzero values at free rows, and the saddle- +point solve produces a `du` that drives free DOFs *away* from u_lin to "fix" +the spurious residual. + +Symptom in 2D heterogeneous case: in ParaView, free DOFs appear to move in +the *opposite* direction from u_lin while corners stay correct. This was the +multi-step driver bug from session 6. The fix: pass *both* K_full and K_e +into the driver, use K_full for r1 computation, K_e for the saddle-point top +block. + +In 2D Phase-2 single-step working code, K was assembled, then `K.Mult(u_lin, +f)` happened, *then* corner elimination was applied to K and to f +simultaneously (`apply_dirichlet_to_distributed_K`). Order of operations +saved us. The multi-step driver moved corner elimination outside the driver, +breaking the implicit assumption. + +## §6.5 The Newton residual (when nonlinear) + +For nonlinear K (= ∂F_int/∂u from a nonlinear material), the Newton residual +at iterate (u^k, λ^k) is: + +r1^k = F_int(u^k) + Cᵀ · λ^k (force balance) +r2^k = C · u^k − g (constraint residual; g=0 for fluctuation periodicity) + +The Newton step solves [[K^k, Cᵀ], [C, 0]] [du, dλ] = [−r1^k, −r2^k]. + +Critical: r1 includes the +Cᵀ · λ^k term. Naively using F_int(u^k) alone +gives a residual that doesn't go to zero at convergence — it stagnates at the +natural force scale of the problem because at equilibrium F_int = −Cᵀλ, not +zero. See the §12 trap list. + +For the linear-elastic prototype with one Newton iteration, F_int(u) = K·u, +λ⁰ = 0, so r1 = K·u_lin (computed via K_full as discussed in §6.4). + +## §6.6 Sign conventions in the saddle-point API + +To eliminate sign-error bugs we converged on this API for `SaddlePointSolver.solve_step`: + +```python +def solve_step(self, *, K_op, C_op, CT_op, r1_local, r2_local): + """Solve the constrained Newton step. + + The system solved is + [[K C^T] [du ] [-r1_local] + [C 0 ]] [dλ ] = [-r2_local] + + Caller assembles the FULL Newton residuals r1, r2 (including any C^T λ + contribution). Solver simply negates them. + """ +``` + +The solver internally negates `r1_local` and `r2_local` to form the RHS. This +removes ambiguity: the caller computes the residual *as written in the +literature* (∇L, including the Cᵀλ term in r1 and the constraint mismatch in +r2), and the solver always produces the correct (du, dλ) update. + +## §6.7 SetIterativeMode(False) on the inner Krylov + +This is a defensive pattern. The inner Krylov solves for *increment* (du, dλ), +which has no relationship to the previous Newton iteration's increment. If +`SetIterativeMode(True)` is set, the Krylov solver treats the incoming du as +an initial guess — but we always pass zero, so it's a no-op… + +Except for CG specifically, an iterative-mode initial guess that's been +zeroed but is passed through a `BlockVector` of mixed zero-and-nonzero blocks +*can* trigger Lanczos breakdowns or poor convergence. Even though we use +MINRES/GMRES/BiCGStab and not CG, the false negative is cheap to avoid. +Set `SetIterativeMode(False)` always. + +The Newton outer loop *does* warm-start at the outer level: u and λ accumulate +across Newton iterations. That's correct; the inner Krylov is something +different. + +--- + +# §7. Warm-start theory: from ExaConstit's `SolveInit` to multi-step F ramping + +## §7.1 The problem warm-starts solve + +In a multi-step load history, each step n+1 inherits the converged kinematic +state at step n. If between steps n and n+1 the boundary conditions change +(e.g. the prescribed displacement at the corners shifts because F_macro +shifted), then the previous-step state is *no longer in equilibrium with the +new boundary*: free DOFs are still at their step-n values while corner DOFs +must jump to their step-n+1 values. + +Starting Newton from this misaligned state is risky: + +- **Mild case**: Newton converges in extra iterations, with the first iterate + showing a large residual that just reflects the BC mismatch. +- **Severe case**: the first Newton iterate puts the material into a state + that's outside the basin of convergence — for hyperelastic models, this can + mean elements with `det(F) ≤ 0`, which can return NaN or otherwise crash + the integrator. +- **Crystal-plasticity-specific**: for rate-dependent models, the prior + velocity field is a state the integrator depends on. A bad initial iterate + leads to non-physical guesses for the slip-system rates. + +The ExaConstit-style warm-start projects the BC change through the +*previous-step tangent* to produce a sensible initial iterate that has the +new corner displacements applied AND has the free DOFs adjusted by a single +linear solve to be approximately consistent with those new corner values. + +## §7.2 ExaConstit's `SystemDriver::SolveInit` (the reference) + +Sources: +- `src/system_driver.cpp:441-478` (`SolveInit`) +- `src/fem_operators/mechanics_operator.cpp:295-331` (`GetUpdateBCsAction`) + +The pattern is, in pseudo-code: + +```cpp +// Before Newton step n+1. +// State: x_n (converged), v_n (converged), prescribed_v at step n+1 known. + +deltaF = 0; // size: n_TDOF +deltaF[essential_TDOFs] = prescribed_v[ess] - v_n[ess]; // change in BC + +// Build a special operator that: +// 1. Computes b = K_full @ deltaF on FREE rows (the K_uc · Δv_c term). +// 2. Adds the residual at the previous-converged state (= 0 at convergence, +// nonzero if step n didn't quite converge — captures leftover imbalance). +// 3. Combines: y = K_uc · Δv_c + R^n on free rows. +oper = mech_operator->GetUpdateBCsAction(v_n, deltaF, b); + +// Solve the eliminated system K_eliminated @ Δv = -b for Δv on free rows. +// CG (this is a positive-definite system; no constraints involved here). +CG_solve(K_eliminated, -b, Δv); + +// Initial iterate for Newton step n+1 is: +// v_initial = v_n + deltaF + Δv +// = v_n on free DOFs (Δv ≈ 0 if v_n was good) + (correction) +// = prescribed_v[ess] on essential DOFs (deltaF puts them there exactly) +// = v_n + Δv elsewhere (the projected correction) +v_initial = v_n + deltaF + Δv; + +// Now run Newton from v_initial. +Newton_from(v_initial); +``` + +Two key insights: + +1. **`deltaF` is nonzero ONLY at essential DOFs.** It captures the change in + corner displacement (or velocity, for ExaConstit's velocity primal). At + non-essential DOFs deltaF = 0. +2. **`K_full @ deltaF` extracts the K_uc · Δv_c contribution.** Because deltaF + has nonzero values only at essential cols (= corners), `K_full @ deltaF` + at free rows equals K_uc · deltaF[ess] — exactly the change in residual at + free rows caused by the BC change. + + The `K_eliminated` version would give zero (K_uc cols zeroed by + elimination). So `GetUpdateBCsAction` must use the un-eliminated K — same + K_full vs K_eliminated distinction we already saw in §6.4. + +`GetUpdateBCsAction` implements this by temporarily setting the essential +TDOF list to *empty* on the local Jacobian (so the action of K is computed +as the full operator), then calling `local_jacobian.Mult(deltaF, y)`, then +restoring the original essential TDOF list. The previous-state residual is +added, and corner entries of the result are zeroed (so the inner CG solve +doesn't try to "fix" the essential rows, which are already correct). + +## §7.3 Translation to displacement primal (our setting) + +Our prototype's primal is u (displacement), not v (velocity). The translation: + +| ExaConstit | Mortar PBC prototype | +|---|---| +| v_n converged at step n | u_n converged at step n | +| prescribed_v[ess] at step n+1 | u_lin[corner] at step n+1 = (F^{n+1} − I)·X[corner] | +| deltaF = prescribed_v[ess] − v_n[ess] at corners | deltaF[corner] = u_lin^{n+1}[corner] − u_n[corner] = (F^{n+1} − F^n)·X[corner] | +| K_n = local Jacobian at v_n | K_n = K = ElasticityIntegrator(λ, μ) — independent of u for linear elastic | +| ΔR_u = -K_uc · Δv_c | ΔR_u = -K_uc · deltaF | +| Solve K_e Δv = -(R^n + ΔR_u) | Solve [[K_e, Cᵀ], [C, 0]] [Δv, Δλ] = [-(R^n + ΔR_u), -C·deltaF] | +| v_initial = v_n + deltaF + Δv | u_initial = u_n + deltaF + Δv | + +Two key differences: + +1. **The constraint coupling**: ExaConstit's `SolveInit` is a *bare* CG solve, + no Lagrange multipliers. Our setting has the mortar constraint, so the + warm-start projection is itself a saddle-point solve (using the same + `SaddlePointSolver` we use for the main Newton step). This ensures the + projected initial state is *also* mortar-periodic. + +2. **R^n is zero in linear elastic**: for our prototype, the previous step + converged to machine precision (linear system), so R^n = 0. The R^n term + is included for nonlinear / sub-converged future use. + +## §7.4 Derivation of the projection equation + +We now derive the projection equation explicitly. Suppose at step n the +state (u^n, λ^n) satisfies, after corner BC are applied: + + K(u^n) · u^n + Cᵀ λ^n = 0 (force balance on free DOFs) (7.1a) + C · u^n = 0 (mortar periodicity) (7.1b) + +with corner DOFs already at u_lin^n[corner]. + +At step n+1, prescribe new corner values: u^{n+1}[corner] = +u_lin^{n+1}[corner]. The free DOFs and λ are unknown. We seek an *initial +iterate* u^{n+1, 0} = u^n + Δu that: + +(i) Has the new corner values exactly: u^{n+1, 0}[corner] = + u_lin^{n+1}[corner]. +(ii) Approximately satisfies (7.1a) with K linearised at u^n. +(iii) Exactly satisfies (7.1b) for the new state. + +From (i): Δu[corner] = u_lin^{n+1}[corner] − u^n[corner] = +u_lin^{n+1}[corner] − u_lin^n[corner] = (F^{n+1} − F^n) · X[corner], let's +call this **deltaF**. + +So we decompose Δu = deltaF + Δv, where deltaF has nonzero entries only +at corners, and Δv has zero corner entries (free-DOF correction). + +Linearise (7.1a) about u^n: + + K(u^n) · (u^n + Δu) + Cᵀ (λ^n + Δλ) = 0 + K(u^n) · u^n + K(u^n) · Δu + Cᵀ λ^n + Cᵀ Δλ = 0 + R^n + K(u^n) · Δu + Cᵀ Δλ = 0 (7.2) + +where R^n := K(u^n) · u^n + Cᵀ λ^n is the residual at step n (zero at +clean convergence; nonzero if step n didn't quite converge — we capture +this term for robustness). + +Substitute Δu = deltaF + Δv into (7.2): + + R^n + K · (deltaF + Δv) + Cᵀ Δλ = 0 + K · Δv + Cᵀ Δλ = − R^n − K · deltaF (7.3a) + +Linearise (7.1b): + + C · (u^n + Δu) = 0 + C · u^n + C · Δu = 0 + 0 + C · (deltaF + Δv) = 0 + C · Δv = − C · deltaF (7.3b) + +Stack (7.3a) and (7.3b) into the saddle-point form: + + ┌ K_e Cᵀ ┐ ┌ Δv ┐ ┌ −(R^n + K_full · deltaF) ┐ + │ │ │ │ = │ │ (7.4) + └ C 0 ┘ └ Δλ ┘ └ − C · deltaF ┘ + +with corner rows handled as in §6.4: K_e (eliminated K) is used in the +saddle-point top block (with corner Dirichlet built in via the identity +rows), but `K_full · deltaF` is computed using the FULL un-eliminated +K because deltaF is nonzero at corners (the K_uc · deltaF[corner] term +matters — see §6.4 trap 1). + +After solving (7.4), the warm-start initial iterate is: + + u^{n+1, 0} = u^n + deltaF + Δv (7.5) + +with corners at u_lin^{n+1}[corner] (because deltaF supplies the change +exactly at corners and Δv has zero corner entries). λ^{n+1, 0} = +λ^n + Δλ. + +**For linear K**, (7.4) IS the exact Newton step from u^n + deltaF (which +already has correct corners but wrong free-DOF values), and Δv brings +the free DOFs to the new equilibrium in one solve. Newton has nothing +left to do at step n+1 — see §7.5. + +**For nonlinear K**, (7.4) gives an *initial iterate* in Newton's basin +of attraction; Newton then converges in 2-3 iterations rather than +5-10 if started cold from u^n + deltaF (which has corner-induced +imbalance) or even more iterations if started from u^n (where corners +are wrong). + +## §7.5 Why warm-start is degenerate for linear elastic + +For a fully-linear problem, each step is independent: the answer at step n+1 +is determined entirely by F^{n+1} and the geometry/material; it does *not* +depend on the step-n state at all. The "warm-start projection" with linear K +gives the *exact* answer in one solve — there's nothing left for Newton to do. + +So in the linear-elastic prototype: + +- `solve_first_step(F_1)`: builds u_lin^1, solves saddle-point for du, + forms u^1 = u_lin^1 + du. This is an *independent* solve. +- `solve_next_step(F_2)`: in principle, applies the warm-start recipe and + finds u_initial that's already at the new equilibrium. *In practice for + linear elastic, this reduces to "solve fresh"* — same answer. We + implement it as a re-invocation of `_solve_independently(F_2)` and + document why. + +The architecture is in place for the eventual nonlinear extension: + +- `MortarPbcDriver2D` carries `K_op_full`, `K_op` (eliminated), `C_op`, `CT_op`, + state `u_par`, `lam_par`, `F_prev`. +- `solve_next_step` for nonlinear materials would: + 1. Compute deltaF: zero everywhere, fill corners with `(F^{n+1} − F^n)·X[corner]`. + 2. Compute b = K_full · deltaF, zero corner entries. + 3. Add R^n if available (zero at clean convergence). + 4. Solve saddle-point for (Δv, Δλ) per (7.4). + 5. u_initial = u_n + deltaF + Δv. Set Newton's initial iterate. + 6. Run Newton from u_initial. + +This recipe is documented in `MortarPbcDriver2D.solve_next_step` for direct +translation when the Newton outer loop is added back (after pyMFEM's +NeoHookean integrator is fixed or replaced). + +## §7.6 Subtlety: "prev-state mesh-coordinate corruption" + +A trap we hit: the visualization writer was warping the mesh nodes after each +solve and *not* restoring them to reference. Subsequent calls to +`apply_linear_part(fes, F^{n+1})` projected `(F^{n+1} − I) X` against the *deformed* +mesh nodes, giving u_lin values that grew with each step (the affine field +was being applied to already-displaced X coordinates). + +Symptoms: +- u_lin at step k looked "more stretched" than it should be by a factor of (1 + cumulative-strain). +- The volume-averaged-F diagnostic *still showed* ⟨F⟩ = F_macro to + machine precision — because both `apply_linear_part` and `compute_volume_averaged_F` + used the same deformed mesh. They were internally consistent with each other, + consistent with the wrong reference. +- The SciPy direct cross-check failed by ~6%, because the K matrices were + *static* (assembled at start, never touched), so they corresponded to the + reference mesh, but the gathered u_lin at the verification block was + computed against the deformed-from-step-3 mesh. Two different reference + frames in the same linear system. + +The fix: `PbcVisualizationWriter.write_step` now resets the mesh to the +reference snapshot *after* saving each cycle. The writer is side-effect-free +with respect to the mesh; every operation outside the writer always sees the +reference configuration. + +This is the **total-Lagrangian discipline** in code form. See §9 for the +broader framing. + +--- + +# §8. Diagnostics: volume-averaged F as the consistency check + +## §8.1 The Hill-Mandel average theorem + +[Hill 1972; Mandel 1972] establish that for a heterogeneous body Ω in a +homogenisation context, the macroscopic stress-strain pair must derive +from a microscale BVP whose volume-averaged kinematics equal the +prescribed macroscale F. We verify this for the periodic case explicitly. + +Decompose u = u_lin + ũ on Ω, with u_lin = (F_macro − I) X and ũ +periodic on opposite faces of ∂Ω. + +The deformation gradient F = I + ∇u = I + ∇u_lin + ∇ũ. Its volume +average is: + + ⟨F⟩_Ω = (1/V_Ω) ∫_Ω F dV + = (1/V_Ω) ∫_Ω (I + ∇u_lin + ∇ũ) dV + = I + (1/V_Ω) ∫_Ω ∇u_lin dV + (1/V_Ω) ∫_Ω ∇ũ dV (8.1) + +The first integral evaluates to: + + (1/V_Ω) ∫_Ω ∇u_lin dV = (1/V_Ω) ∫_Ω (F_macro − I) dV + = F_macro − I (8.2) + +since (F_macro − I) is constant. The second integral is the key — we +claim it vanishes for periodic ũ. + +**Proposition** (Hill-Mandel for periodic boundary): + + ∫_Ω ∇ũ dV = 0 for ũ Ω-periodic. (8.3) + +**Proof.** Apply the divergence theorem (Gauss's theorem) componentwise. +The (i,j) component of ∇ũ is ∂ũ_i / ∂X_j, so: + + ∫_Ω (∇ũ)_{ij} dV = ∫_Ω ∂ũ_i / ∂X_j dV = ∮_{∂Ω} ũ_i N_j dA (8.4) + +In tensor form: ∫_Ω ∇ũ dV = ∮_{∂Ω} ũ ⊗ N dA. + +Partition ∂Ω into pairs of opposite faces (Γ_k^+, Γ_k^-) for k = 1, …, d. +On the pair (Γ_k^+, Γ_k^-) the outward unit normals are N^+ = +e_k and +N^- = −e_k respectively (axis-aligned cube; the argument generalises by +periodic identification for arbitrary periodic shapes). + +Periodicity says ũ takes the same value at points X ∈ Γ_k^- and Π(X) ∈ +Γ_k^+ where Π is the periodic mapping. So on the pair: + + ∫_{Γ_k^+} ũ ⊗ N^+ dA + ∫_{Γ_k^-} ũ ⊗ N^- dA + = ∫_{Γ_k^+} ũ ⊗ (+e_k) dA + ∫_{Γ_k^-} ũ ⊗ (−e_k) dA + = (∫_{Γ_k^+} ũ dA − ∫_{Γ_k^-} ũ dA) ⊗ e_k (8.5) + +By periodicity of ũ and the area-preserving mapping Π: + + ∫_{Γ_k^+} ũ dA = ∫_{Γ_k^-} ũ dA (8.6) + +so (8.5) is zero. Summing over all d pairs of opposite faces: + + ∮_{∂Ω} ũ ⊗ N dA = 0 ⟹ ∫_Ω ∇ũ dV = 0. ∎ + +Substituting (8.2) and (8.3) into (8.1): + + ⟨F⟩_Ω = I + (F_macro − I) + 0 = F_macro. (8.7) + +**Implication.** ⟨F⟩_Ω = F_macro **independent of any internal +heterogeneity, mesh refinement, or constitutive law**. The result holds +whenever ũ is *exactly* periodic. It's a property of the kinematic +constraint, not of the elastic problem. + +This makes the volume-averaged F the *single most important consistency +check* on any PBC implementation: + +- If ⟨F⟩ = F_macro to machine precision: the discrete periodicity is + right AND the displacement field is correct (modulo the reference- + frame caveat — see §8.3). +- If ⟨F⟩ ≠ F_macro: something is wrong. Either the constraint isn't + enforcing periodicity correctly, or the corner Dirichlet isn't right, + or the post-processing is using the wrong mesh state, or the + integration is subtly off. + +## §8.2 Implementation + +`mortar_pbc.compute_volume_averaged_F(pmesh, fes, u_par)`: + +```python +for each local element e: + eltrans = fes.GetElementTransformation(e) + ir = mfem.IntRules.Get(fe.GetGeomType(), 2*order+1) + for each Gauss point q: + eltrans.SetIntPoint(q) + w = q.weight * eltrans.Weight() + gf_u.GetVectorGradient(eltrans, grad_u_at_qp) + accumulate w * grad_u_at_qp into grad_u_acc + accumulate w into vol_acc +allreduce(grad_u_acc, vol_acc) +return I + grad_u_acc / vol_acc +``` + +This is dimension-agnostic — works in 2D and 3D unchanged. The integrand +`grad_u_at_qp` is dim×dim. In 3D we Allreduce 9 doubles instead of 4. + +## §8.3 What ⟨F⟩ catches + +The diagnostic catches: + +- Constraint matrix C built incorrectly (e.g. wrong dual basis, missing + Wohlmuth modification, wrong nonmortar/mortar pairing). +- Corner Dirichlet applied at the wrong values. +- Mesh-state-corruption in post-processing (the "deformed mesh as reference" + bug from §7.6). +- Integration order too low (would produce small-but-nonzero error). + +The diagnostic does *not* catch: + +- Bugs internal to the FE assembly (e.g. wrong material tensor) — those + show up as wrong stress, not wrong ⟨F⟩. +- Sub-converged Newton (the diagnostic measures ⟨F⟩ for whatever u_par was + passed; if u_par is sub-converged, ⟨F⟩ may still match F_macro because + the constraint is satisfied even if equilibrium isn't). + +## §8.4 PASS criterion threshold + +For our 2D prototype: `|⟨F⟩ − F_macro|_max < 1e-9`. Linear elastic with +direct-quality Krylov convergence, this should typically be `< 1e-13` — +machine precision. The 1e-9 threshold is loose enough to allow for some +preconditioner-quality slack while still being orders of magnitude below +"physically correct" tolerances. + +For 3D, the threshold should hold (1e-9 or tighter). The integral is +direction-symmetric, so 3D doesn't change the precision target. + +--- + +# §9. Visualisation and the total-Lagrangian discipline + +## §9.1 The discipline + +All operations on the FE mesh — assembly, projection, gradient evaluation, +integration, residual computation, K computation — happen on the **reference +configuration**. The deformed mesh is purely a visualisation artefact. We +never compute against the deformed mesh. + +This is the **total-Lagrangian** convention. ExaConstit, despite using +"updated-Lagrangian" terminology at the macroscopic time-step level, uses +total-Lagrangian within each load step's solve: the integrator references +the reference configuration to evaluate F, σ, K. ExaConstit's "updated" +aspect is that *between* load steps, the converged state propagates as the +new initial state — but the reference geometry doesn't actually change. (This +is a mild abuse of terminology in the field; the distinction matters less +than the practice.) + +## §9.2 Why this matters in code + +Two specific places where the reference-vs-deformed distinction got us into +trouble: + +1. **`apply_linear_part(fes, F)`**. Internally calls + `gf.ProjectCoefficient(coef)` where `coef.EvalValue(x)` returns + `(F − I) · x`. The "x" here is whatever the *current* mesh's nodal + coordinates are. If the mesh has been warped to deformed, `x = X + u_prev`, + and `apply_linear_part` returns `(F − I) (X + u_prev)` — a function of the + accumulated displacement, not the reference position. This silently + produces wrong u_lin values. + +2. **`compute_volume_averaged_F(pmesh, fes, u_par)`**. Calls + `gf_u.GetVectorGradient(eltrans, grad_u_at_qp)`. The `eltrans` is built + from the mesh's current nodal coordinates. ∇u in the deformed + configuration ≠ ∇u in the reference configuration (they differ by the + deformation gradient itself, which is the very thing we're trying to + compute). If the mesh is deformed, ⟨F⟩ from this routine is wrong. + +The fix is in `PbcVisualizationWriter`: on every `write_step`, *reset* the +mesh to the reference configuration *after* saving the deformed cycle. The +writer is the only piece of code that ever touches the mesh nodes; every +other operation sees the reference. + +## §9.3 The mesh-node update mechanics + +To "reset to reference" requires: + +1. Snapshot the reference node coordinates at `PbcVisualizationWriter` + construction time, before any solve runs. +2. To warp: read the reference snapshot, add the displacement, write back. +3. To reset: read the reference snapshot, write back unchanged. +4. After every reset/warp, call `pmesh.NodesUpdated()` to invalidate cached + geometric factors (otherwise MFEM will use stale `eltrans` from before the + nodes changed). + +The MFEM API for this: + +```python +nodes_gf = pmesh.GetNodes() # ParGridFunction of node coords +ref_tdofs = mfem.Vector() +nodes_gf.GetTrueDofs(ref_tdofs) # snapshot at ctor time +ref_snapshot = np.array(ref_tdofs.GetDataArray(), copy=True) + +# Later: reset to reference +for i in range(ref_tdofs.Size()): + ref_tdofs[i] = float(ref_snapshot[i]) +nodes_gf.SetFromTrueDofs(ref_tdofs) +pmesh.NodesUpdated() +``` + +## §9.4 The byNODES vs byVDIM ordering trap + +A subtle MFEM-default trap: when you build a vector FE space via +`ParFiniteElementSpace(pmesh, fec, vdim=dim)`, the default ordering is +**Ordering::byNODES**. When you call `pmesh.SetCurvature(order)`, the default +ordering of the resulting nodal grid function is **Ordering::byVDIM**. + +These are different layouts: +- `byNODES`: TDOFs listed as `[u_x(0), u_x(1), ..., u_x(N), u_y(0), ..., u_y(N), ...]` +- `byVDIM`: TDOFs listed as `[u_x(0), u_y(0), u_x(1), u_y(1), ...]` + +If your displacement FES is byNODES and your mesh-nodes FES is byVDIM, +`for i in range(n_tdof): nodes[i] += u_par[i]` silently swaps x and y +components, producing a 90°-rotated warp. + +The fix: explicitly pass the desired ordering to `SetCurvature`: + +```python +pmesh.SetCurvature(order=1, discont=False, space_dim=-1, ordering=fes.GetOrdering()) +``` + +Now the nodal grid function shares the displacement FES's ordering. The unit +test `_ensure_nodal_with_matching_ordering` handles this defensively, and +`_warp_mesh_by` asserts the orderings match before mutating. + +--- + +# §10. Status at the Phase-2 ↔ Phase-3 boundary + +## §10.1 Verified-passing as of this commit + +| Test | Verified | +|---|---| +| Unit tests, 2D suite (6 tests) | PASS on np=1; pure-Python, no MPI | +| Unit tests, 3D Phase 3.2.A suite (25 tests) | PASS on np=1; pure-Python, no MPI | +| Unit tests, 3D Phase 3.2.B suite (11 tests) | PASS on np=1; pure-Python, no MPI | +| Unit tests, 3D Phase 3.3.A suite (4 tests) | PASS on np=1; verifies `MortarAssembler2D` reuse on `EdgeInfo3D` (axis-generic dispatch, x/y/z symmetry) | +| Unit tests, 3D Phase 3.3.B helpers (8 tests) | PASS on np=1; pure-Python helpers in `BoundaryClassifier3D` (boundary-tag dispatch incl. 3-sentinel quad, axis inference, face-bounding edges, CCW reordering, end-to-end sentinel-tagged assembler dispatch) | +| Unit tests, 3D Phase 3.3.C suite (5 tests) | PASS on np=1; pure-Python with synthetic 2×2×2 mock classifier (row count, constant-field nullspace, affine-field jump, linearity, sparsity / face-row column targeting) | +| `examples/patch_test_2d.py` (Phase 1B linear-elastic baseline) | PASS np = 1, 2, 4, 8 | +| `examples/patch_test_2d_heterogeneous.py` (5× strip-split, multi-step) | PASS np = 1, 2, 4, 8 with `--F=uniaxial`, `--F=shear`, `--F=mild-shear`, `--steps=1..N` | +| `examples/patch_test_2d_checkerboard.py` (5× 4-quadrant XOR, multi-step) | PASS np = 1, 2, 4, 8, all F choices | +| `examples/patch_test_3d_homogeneous.py` (Phase 3.1 hex+tet, full-∂Ω Dirichlet) | PASS np = 1, 2, 4, 8 with `--mesh-type hex` and `--mesh-type tet`; `--paraview` validates visually | +| `examples/probe_boundary_classifier_3d.py` (Phase 3.3.B integration smoke-test) | PASS np = 1, 4 with `--mesh-type hex` and `--mesh-type tet` | +| `examples/probe_constraint_builder_3d.py` (Phase 3.3.D integration smoke-test) | Pending Robert's macOS validation; sandbox lacks pyMFEM | + +The 3D Phase 3.2.A unit suite (`tests/test_mortar_3d_unit.py`) verifies: + +- Lumped-positivity precondition (§4.9.1) for all 9 element types in + scope, with correct sign pattern: line-2 / line-3 / tri-3 / quad-4 / + quad-9 / tet-4 all-positive (PASS list); tri-6 corner = 0; quad-8 + corner < 0; tet-10 corner < 0 (FAIL list, see §4.9.2 for the + dimension-dependent simplex pattern). +- Bi-orthogonality of M_tri3_dual, M_quad4_dual, M_tet4_dual on + reference elements to ~1e-16 precision. +- Partition of unity of all standard FE shape functions and the + implemented dual bases. +- Wohlmuth modifications (eqs. 5.5, 5.6, 5.8, 5.10): tri-3 with 0/1/2/3 + vertices dropped; quad-4 edge-adjacent and corner-adjacent. +- Conforming-pair lumping recovery (eq. 3.8) on the *kernel* level + (single-element bi-orthogonality verification). + +The 3D Phase 3.2.B unit suite (`tests/test_face_mortar_3d.py`) verifies +the face-mortar *assembler* (the pure-Python LOOP layer that consumes +QuadFaceElement / TriFaceElement data and produces FaceMortarPairBlock): + +- Lumped-positivity construction guard: `QuadFaceMortarAssembler()` / + `TriFaceMortarAssembler()` instantiate cleanly; a hypothetical + tri-6-style broken-basis subclass raises `RuntimeError` at __init__. +- Single-element conforming-pair recovery for quad-4 and tri-3: + D = A_m = (face_area / n_nodes) · I_n to ~1e-13 precision. +- 2×2 grid quad-4 conforming pair: D pattern = (1, 2, 1, 2, 4, 2, 1, + 2, 1) · 0.25 (matches per-node sub-element-count weighting); A_m = + diag(D). +- Sentinel-row drop on quad-4 with `gtdofs = (0, -1, 1, 2)`: the + corresponding row is absent from D and A_m; off-diagonal mortar-col + zero-pattern matches the kept (3, 4) block. +- Wohlmuth corner-LL modification on quad-4: corner row dropped via + sentinel; D rows unchanged from unmodified case (D uses standard N, + not modified M); A_m row sums DIFFER (modification active); + modified dual partition-of-unity preserved at every Gauss point. +- Wohlmuth tri-3 v0 (one-vertex-dropped, edge-adjacent): kept (2, 3) + block; cols (1, 2) = I_2 ((|T|/3) per diagonal); col 0 leak = 0.5 + (non-zero, consistent with eq. 5.5 verification — the "harmless + leak" into the dropped corner column). +- `match_conforming_face_pairs` helper: 9-element grid pairs with + identity perm; shuffled-mortar order recovered correctly; + non-conforming 2×2 vs 3×3 raises `RuntimeError`. + +PASS criteria, unified across drivers: + +- Krylov converges (`sps.last_converged == True`). +- `||C u_tilde||_2 < 1e-8` (constraint residual, machine precision typical). +- `||u_tilde||_inf > 1e-12` (heterogeneous must produce non-trivial fluctuation). +- `||du_krylov − du_direct||_inf < 1e-6` (Krylov vs. SciPy direct + cross-check; typically ~1e-13 in practice). +- `|⟨F⟩ − F_macro|_max < 1e-9` (homogenization consistency; typically ~1e-15). + +**Doc correction surfaced during Phase 3.2 implementation.** The +original §4.9.2/§4.9.3 claimed tet-10 corner s = 0 by analogy with +tri-6. Direct numerical evaluation (matching the closed-form +arithmetic) gives s_corner = −|T|/20 = −1/120 instead. The §4.9 +section now contains the corrected dimension-dependent simplex +formula (eq. 4.28b): s_corner_P2 = (2−d)/((d+1)(d+2)) · |T|, which +is positive for d=1, zero only at d=2, and negative for d≥3. This +sharpens the predictive lumped-positivity rule and is exactly the +kind of correction the unit-test suite was designed to surface. + +**Doc correction surfaced during Phase 3.1 macOS validation.** The +original §11.8 Phase 3.1 design pinned only the 8 corners at u_lin +and predicted u = u_lin elsewhere "because the affine field is the +exact solution." This is incorrect: with corner-only Dirichlet, the +rest of ∂Ω carries the natural BC σ·n = 0, which is incompatible +with the constant stress σ = C : sym(F-I) of the affine field. +Robert's macOS run produced ‖K · u_lin‖_∞ ≈ 589 (the integrated +boundary traction σ·n, NOT noise) and ‖du‖_∞ ≈ 7e-2 (a non-affine +minimum-energy field that satisfies σ·n = 0 on the free boundary). +The correction in §11.8 promotes Phase 3.1 to FULL Dirichlet on all +6 boundary faces at u_lin, which makes interior DOFs the only free +ones and recovers (K · u_lin)_i = 0 for all interior i (∫∇N_i dV = 0 +for compactly-supported N_i). This is the standard linear-elasticity +patch test; the role of mortar PBC at Phase 3.4 is precisely to +*replace* the missing free-Neumann boundary tractions with periodic +nonmortar-mortar coupling, restoring well-posedness with only 8 corner +Dirichlets. + +**MPI deadlock surfaced during Phase 3.1 np > 1 validation.** The +3D driver originally had `n_global_elements = pmesh.GetGlobalNE()` +inside an `if rank == 0:` block. `ParMesh::GetGlobalNE()` is a +COLLECTIVE in MFEM (it does an internal `MPI_Allreduce` summing +per-rank element counts across the ParMesh communicator); calling it +only on rank 0 strands rank 0 inside the Allreduce while ranks 1..N-1 +fly past and reach the next collective (`ParFiniteElementSpace`) +alone. Symptom: clean execution at np = 1, hang after the first +collective at np ≥ 2. The fix — call collectives on ALL ranks, then +guard only the print with `if rank == 0` — was already documented +in §11.7 but missed in the 3D driver. The same trap was warned +about explicitly in `examples/patch_test_2d.py` lines 649-654; we +now have a matching warning comment in the 3D driver and a §10.4 +"distributed-driver invariants" subsection summarising the rule. + +## §10.2 What the prototype currently provides + +Capabilities: +1. 2D mortar PBC for non-conforming RVE meshes (rectangular geometry). +2. Linear elastic constitutive model via `ElasticityIntegrator` + + `PWConstCoefficient` for piecewise-constant Lamé parameters. +3. Method D (total-displacement primal) with corner Dirichlet at u_lin[corner] + and mortar fluctuation periodicity. +4. Wohlmuth-modified dual basis at corner crosspoints (Lopes Eq. C.2), + verified by unit test. +5. Distributed Krylov saddle-point solver (GMRES + block-Jacobi prec). +6. Multi-step driver with ExaConstit-style warm-start architecture (degenerate + for linear elastic; ready for nonlinear extension). +7. Volume-averaged F homogenization diagnostic. +8. ParaView visualization (multi-cycle, mesh-node-warped, byNODES/byVDIM + robust). +9. SciPy direct cross-check on rank 0 for verification. + +Code structure: + +``` +mortar_pbc_proto/ +├── README.md # Quickstart +├── PROJECT_STATUS.md # Pre-Phase-3 status +├── docs/ +│ └── MORTAR_PBC_ARCHITECTURE.md # This document +├── mortar_pbc/ # Pure-Python package +│ ├── __init__.py # Lazy-loaded public API +│ ├── types_2d.py # EdgeNodes2D, CornerInfo +│ ├── boundary_2d.py # BoundaryClassifier2D +│ ├── mortar_2d.py # Dual basis + MortarAssembler2D +│ ├── constraint_builder.py # ConstraintBuilder2D +│ ├── constraint_assembler.py # ABC + stack_constraints +│ ├── saddle_point.py # SaddlePointSolver, prec +│ ├── multistep_driver.py # MortarPbcDriver2D + ⟨F⟩ diagnostic +│ ├── visualization.py # PbcVisualizationWriter +│ ├── diagnostics.py # General diagnostic helpers +│ └── _verify_solver.py # SciPy direct (quarantined) +├── examples/ +│ ├── patch_test_2d.py # Phase 1B baseline +│ ├── patch_test_2d_heterogeneous.py # Strip-split, multi-step +│ ├── patch_test_2d_checkerboard.py # 4-quadrant XOR, multi-step +│ └── diag_neohookean_2x2.py # NeoHookean NaN diagnostic +└── tests/ + └── test_mortar_2d_unit.py # 6 unit tests +``` + +## §10.3 What the prototype doesn't do (and why) + +1. **NeoHookean / nonlinear material**: pyMFEM's `NeoHookeanModel` produces NaN + at u=0 across all constructor variants tested in this build (uniaxial F, + single-material, multi-material, scalar-coefficient, Coefficient-coefficient). + We pivoted to linear elastic for the prototype. Diagnostic preserved in + `examples/diag_neohookean_2x2.py`. Replacement strategies for the production + ExaConstit port: (a) write a custom `HyperelasticModel` subclass that's + numerically robust at u=0; (b) use a different MFEM build; (c) skip + NeoHookean and go straight to crystal plasticity (which is the actual + target). Linear elasticity is sufficient for prototyping the mortar PBC + machinery itself. + +2. **Newton iteration**: with linear elastic K, each step converges in one + solve. The `MortarPbcDriver2D.solve_next_step` documents the warm-start + recipe but for linear elastic implements it as a single fresh solve per + step. Phase-2's earlier neo-Hookean Newton outer loop is preserved in + transcript form for re-introduction when the integrator is fixed. + +3. **Tribol integration for general non-conforming geometry**: deferred. We + built our own mortar machinery to (a) understand the method, (b) own the + integration into ExaConstit's PA path. Tribol may be revisited as an + alternative dual-basis / non-conforming geometry-matching backend; current + prototype handles axis-aligned 2D directly. + +4. **3D**: nothing yet. That's Phase 3, the subject of §11. + +5. **Uniform Traction (UT) BCs**: deferred but architectural hook is in place + (`ConstraintAssembler` ABC + `stack_constraints` helper). Adding UT later + is a matter of writing one new `UniformTractionConstraintAssembler` and + stacking it. + +6. **C++ ExaConstit port**: planned for Phase 5. See §13 for design. + +## §10.4 Distributed-driver invariants (the rank-asymmetric-collective trap) + +This rule has bitten the codebase twice — once in 2D (where it's +explicitly warned against in `examples/patch_test_2d.py` lines +649-654) and once in 3D (Phase 3.1, surfaced during Robert's macOS +np = 4 validation). It deserves a centralised statement. + +**Rule.** A function that internally uses MPI collectives must be +called by ALL ranks at the same point in program order. Wrapping +such a call in `if rank == 0:` causes rank 0 to enter the collective +alone and block waiting for ranks 1..N-1, who fly past and reach the +NEXT collective alone, who block waiting for rank 0. Deadlock. + +**Three-line failure pattern (illustrative).** + +```python +# WRONG — deadlocks at np > 1: +if rank == 0: + n = pmesh.GetGlobalNE() # collective: MPI_Allreduce inside + print(f"global elements = {n}") + +# RIGHT: +n = pmesh.GetGlobalNE() # collective on all ranks +if rank == 0: # rank-0-only print is fine + print(f"global elements = {n}") +``` + +**Known collectives in MFEM that look like local accessors.** Most +of these run inside `if rank == 0:` blocks "by mistake" because +their names suggest a property query rather than a communication: + +- `Mesh::GetGlobalNE()` (when `*this` is a ParMesh) → MPI_Allreduce +- `Mesh::GetGlobalNV()` (when ParMesh) → MPI_Allreduce +- `ParGridFunction::ComputeL2Error(...)` → MPI_Allreduce +- `ParGridFunction::Norml2()` / `Norml1()` / `Normlinf()` → MPI_Allreduce +- `ParBilinearForm::Assemble()` and `ParallelAssemble()` → MPI internal +- `ParFiniteElementSpace::GetEssentialTrueDofs(...)` → has a parallel + fix-up step; at minimum participates in any later assembly fence +- The constructors `ParMesh(comm, mesh)`, `ParFiniteElementSpace(...)`, + `HypreBoomerAMG(K_par)`, `HypreParMatrix::ParAdd(...)`, etc. — + collective by definition. + +**Known collectives in mpi4py that DEFINITELY require all ranks.** + +- `comm.Allreduce(...)`, `comm.Allgather(...)`, `comm.Bcast(...)`, + `comm.Barrier()`, `comm.Reduce(...)` — but `Reduce` on root only is + fine if all ranks call it; the asymmetry is in WHICH ranks call, + not what they pass. + +**Robust pattern for diagnostic prints.** When the value to print is +the result of a collective: + +```python +# Compute on all ranks (collective participates everywhere). +val = some_collective_call(...) + +# Print on rank 0 only (no further collective implied). +if rank == 0: + print(f" diagnostic: {val}") +``` + +When the value is a per-rank quantity that needs to be summed for the +print (e.g., per-rank TDOF counts → global TDOF count): + +```python +# Allreduce on all ranks (collective). +local = compute_local(...) +total = comm.allreduce(local, op=MPI.SUM) + +# Print on rank 0 only. +if rank == 0: + print(f" global total: {total}") +``` + +**When in doubt, instrument.** A `comm.Barrier()` call right before a +suspicious `if rank == 0:` block will surface the deadlock immediately: +the Barrier requires all ranks. If rank 0 enters the Barrier and the +others reach it from the next collective, they all unstick and the +program continues to the actual deadlock site, making it diagnosable. + +This is purely an interface-discipline problem; there's no clever +runtime detection in MPI. Audit drivers against the pattern above +before declaring an np > 1 run "working". + +**Rank-local vs. global indices in cross-rank dedup.** A related +trap surfaced during Phase 3.3.B macOS validation: ``ParMesh`` +vertex indices, element indices, and boundary-element indices are +ALL rank-local. Vertex 27 on rank 0 is unrelated to vertex 27 on +rank 1 — they're indices into each rank's own local arrays. When +AllGather'ing per-rank records that need cross-rank deduplication +(e.g., merging boundary-vertex attribute sets across ranks), keying +the merge dictionary by the rank-local vertex index causes silent +data collisions: the rank-1 record overwrites the rank-0 record +under the same dictionary key, even though they refer to physically +different vertices. + +**The fix is to use a globally-meaningful key.** Two patterns work: + +1. **Snapped physical coordinates** (used by ``boundary_2d`` and + ``boundary_3d``): ``key = round(coord / tol)`` as a tuple. Stable + across ranks because every rank computes the same key from the + same physical position. Requires the parent mesh to use the same + coordinate values across ranks (true for serial-mesh-then- + ParMesh-partition; would need extra care for distributed mesh + readers with curved boundaries). + +2. **Global TDOF numbers** (used in ``ConstraintBuilder2D``): when + the records being merged correspond to FE DOFs, ``GetGlobalTDofNumber`` + returns the same global index from any rank that knows the DOF. + This is preferable when available because it sidesteps coordinate- + precision concerns entirely. + +The general lesson: **never use a rank-local index as a key in a +data structure shared across ranks**. The ``parent_vertex_id`` field +on ``_VertexRecord`` was renamed to ``pvid`` (a synthetic global +counter) once this was understood, to make it a positive cue not to +confuse it with the rank-local parent-vertex index it was originally +populated from. + +## §10.5 MFEM API conventions for attribute arrays (a foot-gun) + +Two MFEM APIs that both take an `Array` of "attributes" use +**different conventions** for what the array contents mean. This +caused a complete classification failure in Phase 3.3.B that +produced "found 0 corners" with no other diagnostic. Documenting +the distinction here so it doesn't bite again. + +**Boolean-mask convention** (used by `GetEssentialTrueDofs` and most +solver-level APIs): + +- Array length = `bdr_attributes.Max()`. +- Entry `i` = 1 selects attribute `i + 1`; entry `i` = 0 deselects. +- Standard usage: + ```python + ess_bdr = mfem.intArray(n_bdr_attrs) + ess_bdr.Assign(1) # select all + fes.GetEssentialTrueDofs(ess_bdr, list) + ``` + +**Attribute-list convention** (used by `SubMesh::CreateFromBoundary`, +`SubMesh::CreateFromDomain`, and similar mesh-derivation APIs): + +- Array length = number of attributes you want to select. +- Each entry IS the attribute integer, listed once per selection. +- Correct usage to select all 6 boundary faces: + ```python + attrs = mfem.intArray(6) + for i in range(6): + attrs[i] = i + 1 # values [1, 2, 3, 4, 5, 6] + ParSubMesh.CreateFromBoundary(parent, attrs) + ``` +- Passing `[1, 1, 1, 1, 1, 1]` as a "boolean mask" instead returns a + submesh of just attribute 1, repeated six times = one face's worth. + No error message — the call silently succeeds with a partial + result. Symptom in our Phase 3.3.B run: classifier produced 25 + vertices on a 4×4×4 hex (the bottom-face vertex count) instead of + the expected 98 boundary vertices. + +**Rule of thumb when adding a new MFEM call that takes an `Array` +of attributes:** check the MFEM source. If the function name suggests +selecting/extracting (CreateFromX, ExtractX, RestrictTo), it almost +certainly takes the attribute-list convention. If the function name +suggests configuring or marking essential/Dirichlet conditions, +it probably takes the boolean-mask convention. When in doubt, write +a 5-line probe with debug output that exercises both cases on a +small mesh and inspect the resulting submesh / DOF-list size. + +--- + +# §11. Extending to 3D: the wirebasket framework + +This is the road map for Phase 3. It exists in this document so that whoever +picks up the work — in this conversation or a future one — has a fully-stated +plan with all the math and architectural decisions called out. Don't start +coding without reading this section. + +## §11.1 The hierarchy and what changes from 2D + +The 2D RVE has 4 corners + 4 edges + (no faces because 2D). The 3D RVE has +8 corners + 12 edges + 6 faces. The constraint structure becomes +*hierarchical* in 3D: + +- **Level 0 (Corners)**: essential Dirichlet, 8 corners × 3 components = 24 + TDOFs. No LM rows; no constraint participation. +- **Level 1 (Edges)**: mortar coupling, with corner LMs dropped. Each pair of + periodic edges gets one constraint group. Wohlmuth modification at corner + endpoints uses the existing 1D recipe. +- **Level 2 (Faces)**: mortar coupling, with edge LMs dropped. Each pair of + periodic faces gets one constraint group. Wohlmuth modification at edge + *boundary strips* — a 2D extension of the 1D corner modification. + +The cascade ensures non-redundancy: each level constrains exactly the DOFs +that aren't already covered by a higher level. + +The full constraint matrix C is then a vertical stack of three blocks: + +``` +C = [ C_edges_x ] ← 3 mortar-coupled edge groups in x direction + [ C_edges_y ] ← 3 mortar-coupled edge groups in y direction + [ C_edges_z ] ← 3 mortar-coupled edge groups in z direction + [ C_faces_yz ] ← 3 face mortar pair (perpendicular to x) + [ C_faces_xz ] ← 3 face mortar pair (perpendicular to y) + [ C_faces_xy ] ← 3 face mortar pair (perpendicular to z) +``` + +(The actual organization may differ slightly — by face/edge group rather than +direction — but the overall stacking is what matters.) + +This stacking is exactly the use case our existing `stack_constraints` +machinery (in `mortar_pbc/constraint_assembler.py`) was designed for. Each +level is a separate `ConstraintAssembler`, and `stack_constraints([...])` +produces the unified C. + +## §11.2 The hex mesh track: hex-8 volumes with quad-4 face mortar + +For hex-mesh RVEs, the periodic boundary structure uses: + +| Level | Element class | Dual basis | Wohlmuth modification | +|---|---|---|---| +| 0 (corners) | hex-8 vertices | (none — essential) | (none) | +| 1 (edges) | line-2 (hex edge) | §4.2 (eq. 4.13) | §5.1 (eq. 5.2) | +| 2 (faces) | quad-4 (hex face) | §4.3 (eq. 4.16) | §5.3 (eq. 5.8 / 5.10) | + +The full algorithmic recipe per face pair, hex-mesh case: + +``` +for each pair of opposite hex-faces (mortar_face, nonmortar_face): + for each quad element Q in nonmortar_face: + classify Q against face boundary: + side_xi = "left" | "right" | "none" + side_eta = "bottom" | "top" | "none" + select dual basis: M_quad4_dual_modified(ξ, η, side_xi, side_eta) + place 2D Gauss quadrature on Q's reference (ξ, η) ∈ [-1,+1]² + for each Gauss point: + x_q = T_Q(ξ, η) # physical point on nonmortar face + x_m = Π(x_q) # periodic image on mortar face + (ξ_m, η_m, mortar_quad_id) = locate(x_m, mortar_face) + evaluate nonmortar M^mod at (ξ, η) + evaluate mortar N at (ξ_m, η_m) + accumulate D_local, A_m_local + assemble into global D, A^m blocks +``` + +Reference for the formulation: [Lopes et al. 2021, §4.4.2; Wohlmuth 2001, +§1.3.4]. + +## §11.3 The tet mesh track: tet-4 volumes with tri-3 face mortar + +For tet-mesh RVEs, the periodic boundary structure uses: + +| Level | Element class | Dual basis | Wohlmuth modification | +|---|---|---|---| +| 0 (corners) | tet-4 vertices | (none — essential) | (none) | +| 1 (edges) | line-2 (tet edge) | §4.2 (eq. 4.13) | §5.1 (eq. 5.2) | +| 2 (faces) | tri-3 (tet face) | §4.4 (eq. 4.19) | §5.2 (eq. 5.5 / 5.6) | + +The hierarchy (level 0 / 1 / 2 of §5.4) is identical; only the level-2 +element class differs. Phase 3.2 must therefore implement BOTH dual bases +and dispatch on face element type. + +The algorithmic recipe per face pair, tet-mesh case: + +``` +for each pair of opposite tet-faces (mortar_face, nonmortar_face): + for each triangle element T in nonmortar_face: + classify T against face boundary: + boundary_nodes = (b1, b2, b3) # per-vertex bool: on face boundary? + select dual basis: M_tri3_dual_modified(λ, boundary_nodes) + place 2D Gauss quadrature on T's reference simplex (barycentric) + for each Gauss point (in barycentric coords): + x_q = T_T(λ_1, λ_2, λ_3) # physical point on nonmortar face + x_m = Π(x_q) # periodic image on mortar face + (λ_m, mortar_tri_id) = locate(x_m, mortar_face) + evaluate nonmortar M^mod at λ + evaluate mortar N at λ_m + accumulate D_local, A_m_local + assemble into global D, A^m blocks +``` + +The differences from the hex case are mechanical: + +- **Quadrature rule**: Dunavant rules [Dunavant 1985] for triangles instead + of tensor-product Gauss for quads. +- **Geometric matching `locate`**: barycentric inverse via affine triangle + transformation (more straightforward than inverse bilinear quad map, + which requires a Newton iteration in the non-axis-aligned case). +- **Boundary classification**: per-vertex booleans (3 bits) vs. + per-edge sides (4 sides on a quad, only relevant if the entire edge + lies on the face boundary). + +A subtle point: a tri-3 face element can have **3 boundary configurations +not present in the quad-4 case**: + +1. **Single vertex on face boundary, no edge on face boundary**: only + one vertex is "on" but the two adjacent edges of the triangle leave + the boundary into the face interior. This is the typical case for a + well-refined triangulated face and uses (5.5). +2. **One edge on face boundary**: two consecutive vertices are "on"; + the corresponding triangle edge lies along the face boundary. The + edge-adjacent modification (eq. 5.5) applies twice — once per "on" + vertex — but care must be taken that they aren't applied + independently. The cleaner formulation: drop both vertices' rows; + the third vertex's M ≡ 1 (this is the §5.2.3 corner-adjacent case + structurally, even though geometrically the triangle is edge-adjacent + not corner-adjacent). +3. **Two edges of triangle on face boundary** (i.e. the triangle is at + a face corner): all three vertices are "on" *or* two are on and one + is interior. The interior vertex's M ≡ 1; this is the (5.6) case. + +Implementation note: pass `boundary_nodes` as the per-vertex bool tuple +and let the `M_tri3_dual_modified` function dispatch on the count +(§5.2.4). This gives the right behavior for all configurations +without case-by-case sign management. + +## §11.4 Mixed hex-tet meshes + +MFEM allows mixed-element meshes where some volume elements are hex-8 +and others are tet-4 in the same `ParMesh`. ExaConstit users may build +such meshes for crystal-plasticity RVEs to mix structured grain +interiors (hex) with topology-conforming grain boundaries (tet). + +Implications for PBC face mortar: + +- **Each periodic face pair may have mixed face elements**. A periodic + face on the y = 0 boundary may consist of some quad-4 faces (from hex + elements bordering this face) and some tri-3 faces (from tet + elements). The opposite y = L face has the *same* mix structurally — + but possibly with different topology because the mesh on each face is + generated independently. +- **Face mortar dispatches per-face**. Each nonmortar-side face element + selects its dual basis (`M_quad4_dual_modified` or + `M_tri3_dual_modified`) based on `face.geom_type`. The mortar-side + face element, accessed via the geometric matching (§3.5), provides + its own shape functions (`N_quad4` or `N_tri3`) and these are + evaluated at the projected (ξ_m, η_m, ...) coordinates regardless of + the nonmortar's element type. +- **Sub-element accuracy** for non-conforming pairs (Phase 3.5): the + Sutherland-Hodgman clipping operates on convex polygons, indifferent + to whether the polygon was a quad or a triangle. Cross-class clipping + (quad nonmortar on tri mortar, or tri nonmortar on quad mortar) is the same + algorithm. + +The architecture: `MortarFaceAssembler` is a virtual base class with +concrete `QuadFaceAssembler` and `TriFaceAssembler` derivatives. The +`ConstraintBuilder3D` walks each face pair and dispatches the +appropriate assembler per nonmortar-side face element. + +For Phase 3.4 (conforming-mesh first), we test: + +- Pure hex RVE (all face elements are quad-4). +- Pure tet RVE (all face elements are tri-3). +- Mixed RVE (some hex, some tet on the same periodic face). + +The mixed test is the hardest correctness check because it exercises +the polymorphic dispatch and the cross-element-class face matching. + +## §11.5 The 3D edge mortar (line-2, common to hex and tet meshes) + +3D edge mortar is element-class-independent: edges of hex-8 and tet-4 +volumes are both line-2 [Lopes et al. 2021, §4.4.1]. The 2D edge mortar +infrastructure (`MortarAssembler2D`) carries forward; we re-use it. + +Two complications versus 2D: + +1. **Each edge has two corner endpoints** (1D corners), and the Wohlmuth + modification (eq. 5.2) applies at both ends. The 1D recipe in + `M_line2_dual_modified` already handles "left" and "right"; an + edge-element adjacent to one corner uses one modification, adjacent + to the other corner uses the other. The implementation works by + passing `side ∈ {"left", "right", "none"}` per edge element. + +2. **Each set of 4 parallel edges forms a periodic group**, not just a + pair. The cube's 12 edges partition into 3 groups of 4 (one group + per axis direction). Within each group, all 4 edges are periodic + equivalents. The mortar coupling per group is: + + - Pick edge e₁ as mortar. + - Couple e₂ ↔ e₁, e₃ ↔ e₁, e₄ ↔ e₁ via 3 line-2 mortar blocks. + - Stack the LM rows: if each edge has n_int interior DOFs after + dropping corners, the group's edge mortar produces 3 × n_int LM + rows per spatial component (one per nonmortar-edge LM DOF, three + nonmortar edges). + +The constraint pseudocode for one direction's edge group: + +``` +for direction d in {x, y, z}: + (mortar_edge, nonmortar_edges[3]) = group_parallel_edges(d) + for each nonmortar edge e in nonmortar_edges: + for each line-2 element L in e: + classify L: side ∈ {"left", "right", "none"} + select dual: M_line2_dual_modified(ξ, side) + place 1D Gauss quadrature on L + for each Gauss point ξ_q: + x_q = T_L(ξ_q) + x_m = Π_d(x_q) # axis-d periodic translation + (ξ_m, mortar_line_id) = locate(x_m, mortar_edge) + evaluate nonmortar M^mod at ξ_q + evaluate mortar N at ξ_m + accumulate D, A^m +``` + +For axis-aligned cubes, `Π_d` is a pure translation by L along axis d +(or − L for the opposite edge). The `locate` step is a 1D parameter +search along the mortar edge. + +## §11.6 The face mortar geometric-matching algorithm + +For each pair of opposite faces (3 pairs in 3D), the face mortar is a +2D mortar over a 2D interface. The algorithm parallels §3.5 with the +following 3D-specific structure: + +``` +function assemble_face_mortar_3d(nonmortar_face, mortar_face, axis): + # axis ∈ {x, y, z}: the periodic translation direction + Π = (x → x ± L * e_axis) # axial translation operator + for each nonmortar face element S in nonmortar_face: + # S may be quad-4 or tri-3 depending on volume element + face_class = classify_against_face_boundary(S, nonmortar_face.boundary) + M_dual = (M_quad4_dual_modified if S.is_quad else + M_tri3_dual_modified) + N_nonmortar = (N_quad4 if S.is_quad else N_tri3) + ir = quadrature_rule(S.geom_type, order=2*p+1) # p = polynomial order + for q in ir.points: + x_q = T_S(q.local_coord) + x_m = Π(x_q) + # Locate mortar element containing x_m + (mortar_elem, m_local_coord) = locate_mortar(x_m, mortar_face) + N_mortar_at_m = (N_quad4(m_local_coord) if mortar_elem.is_quad else + N_tri3(m_local_coord)) + M_at_q = M_dual(q.local_coord, face_class) + w_q = q.weight * |det(J_T_S)| + for i in nonmortar_LM_DOFs: + for j in nonmortar_DOFs: + D_local[i,j] += w_q * M_at_q[i] * N_nonmortar[j](q.local_coord) + for k in mortar_DOFs: + A_m_local[i,k] += w_q * M_at_q[i] * N_mortar_at_m[k] + assemble_block(D_local, A_m_local, S.dofs, mortar_elem.dofs) +``` + +For axis-aligned periodic faces (our case), the `locate_mortar` step +collapses to a 2D parametric search: + +- **Conforming meshes**: `locate_mortar` is direct geometric indexing + (each nonmortar Gauss-point image lies in exactly one mortar element, + identifiable by spatial sort). +- **Non-conforming meshes** (Phase 3.5): the nonmortar-element / mortar- + element overlap may span multiple mortar elements. The integral must + be sub-divided at mortar-element boundaries via Sutherland-Hodgman + clipping (§3.7). Each sub-polygon contributes its own quadrature, and + the contributions accumulate into the same D and A^m. + +For axis-aligned cubes, `locate_mortar` for conforming meshes is: + +```python +def locate_mortar(x_mortar, mortar_face_axis): + # Drop the axis-d coordinate (it's redundant — both faces have the same + # axis-d value modulo periodic translation). + plane_coords = drop_axis(x_mortar, mortar_face_axis) + # Find which mortar element contains plane_coords. + elem_id = mortar_face.spatial_index.locate(plane_coords) + # Compute local coordinates within that element. + local = mortar_face.elements[elem_id].inverse_map(plane_coords) + return (elem_id, local) +``` + +For quad-4 the inverse map requires a Newton iteration in the +general case; for axis-aligned grids, it reduces to two scalar +divisions. For tri-3, the inverse map is an affine 2x2 solve. + +## §11.7 The 3D mesh + boundary classifier + +`BoundaryClassifier3D` is the 3D analog of our 2D classifier. Given an +arbitrary mesh (hex, tet, or mixed) with nodal coordinates and boundary +attributes: + +``` +Input: pmesh, fes +Output: 8 corners (each: TDOF index, X coordinate, attribute) + 12 edges (each: list of TDOF indices interior to the edge, + 2 corner endpoints, parallel direction) + 6 faces (each: list of face-element handles, organised by + face-element type (quad-4 or tri-3), + list of edges bounding the face, + perpendicular direction) +``` + +Geometric classification is independent of element type — it operates on +nodal coordinates only: + +- **Corner**: a node at a vertex of the cube (where 3 boundary + attributes meet, or where 3 face-planes intersect). +- **Edge**: a node on exactly one boundary edge (where 2 boundary + attributes meet), not a corner. +- **Face**: a node on exactly one boundary face (single boundary + attribute), not on any edge. + +For axis-aligned cubes, this reduces to coordinate checks against the +6 face planes: + +```python +def classify_node_3d(coords, eps=1e-12, L=1.0): + """Classify a node into corner / edge / face / interior.""" + on_x_min = abs(coords[0]) < eps + on_x_max = abs(coords[0] - L) < eps + on_y_min = abs(coords[1]) < eps + on_y_max = abs(coords[1] - L) < eps + on_z_min = abs(coords[2]) < eps + on_z_max = abs(coords[2] - L) < eps + n_boundary = sum([on_x_min, on_x_max, on_y_min, on_y_max, + on_z_min, on_z_max]) + if n_boundary >= 3: return "corner" + elif n_boundary == 2: return "edge" + elif n_boundary == 1: return "face" + else: return "interior" +``` + +The `BoundaryClassifier3D` then groups TDOFs by feature, with attention +to MPI distribution: + +- A corner TDOF is owned by exactly one rank (the one that owns the + underlying vertex). +- An edge TDOF is owned by one rank, but several ranks may need to + know about the edge for constraint assembly (analogous to ghost + faces in 2D). +- A face TDOF is owned by one rank. + +For mixed-element meshes, the classifier must additionally: + +- Group face elements by element type (quad vs tri) within each face. +- Ensure that each face-element's geometric vertices have been + classified as corner / edge / face appropriately. +- Propagate the classification to per-face-element boundary + configurations (e.g., for a tri-3 face element, the per-vertex boolean + array `boundary_nodes` of §5.2.4). + +Each rank's `BoundaryClassifier3D` reports the corners / edges / faces +it owns plus the face-element-level data needed to assemble the +constraint matrix block-by-block. + +### §11.7.1 Cross-rank keying: snap-coord global identity + +A subtle but load-bearing implementation detail surfaced during Phase +3.3.B macOS validation: when AllGather'ing per-rank vertex / element +records for cross-rank deduplication, **the dedup key MUST be globally +meaningful**. The two patterns that work in this codebase: + +1. **Snapped physical coordinates** (used by `BoundaryClassifier2D` + and `BoundaryClassifier3D`): + ```python + def snap_key(xyz): + return (round(xyz[0] / tol), + round(xyz[1] / tol), + round(xyz[2] / tol)) + ``` + Stable across ranks because every rank computes the same key from + the same physical position. Requires the parent mesh to have + identical coordinate values on shared vertices across ranks (true + for the `ParMesh(comm, serial_mesh)` partitioning we use). + +2. **Global TDOF numbers** (used in `ConstraintBuilder2D`): when the + records being merged correspond to FE DOFs, `GetGlobalTDofNumber` + returns the same global index from any rank that knows the DOF. + Preferable when applicable because it sidesteps coordinate- + precision concerns. + +What does **not** work as a dedup key: + +- `parent_vertex_id` from `ParMesh.GetVertices()` or the + `parent_vmap` of a `ParSubMesh`. These are RANK-LOCAL indices. + Vertex 27 on rank 0 is unrelated to vertex 27 on rank 1 — they + index into each rank's own local vertex array. Keying a merge + dictionary by these causes silent data collisions: the rank-1 + record overwrites the rank-0 record under the same key, even + though they refer to physically different vertices. + +The original Phase 3.3.B implementation made this mistake. The +symptom at np > 1 was "1 or 2 boundary vertices missing a TDOF +component" — vertices on rank-boundary regions where the collision +left their gtdof tuple incomplete. The fix was to switch the dedup +key to snapped coords; the `_VertexRecord.parent_vertex_id` field +became `pvid` (a synthetic global counter assigned at merge time), +explicitly NOT the rank-local parent vertex index it was originally +populated from. This pattern is cross-referenced in §10.4 +"distributed-driver invariants". + +### §11.7.2 Runtime discovery of attribute → label mapping + +Another implementation detail from Phase 3.3.C macOS validation: +the mapping from MFEM boundary-attribute integers to face labels +(bottom, top, front, back, left, right) **must be discovered at +runtime, not hardcoded**. MFEM's ``MakeCartesian3D`` boundary- +attribute ordering is not part of the documented API contract — +it varies between MFEM versions and between hex vs. tet element +types. + +The bug it caused +----------------- +Phase 3.3.B initially hardcoded: + +```python +_FACE_LABEL_BY_ATTR = { + 1: "bottom", # I assumed y_min + 2: "front", # I assumed z_min + 3: "right", # x_max — correct + 4: "back", # I assumed z_max + 5: "left", # x_min — correct + 6: "top", # I assumed y_max +} +``` + +But on the actual MFEM build under test (4.6+ via pyMFEM commit +7e99b925), attribute 1 corresponds to z_min (front in our +naming), not y_min. The classifier built `FaceInfo3D` records +where ``face_label="bottom"`` (claiming perp=y) was populated +with face elements whose vertices all had **z=0 invariant** — +i.e., quads from the actual front face (z=0). + +Phase 3.3.B's topology checks didn't catch this — the **count** +of corners/edges/faces was correct (8/12/6), and the per-face +quad count was correct (16/face for hex). Only when Phase 3.3.C +called ``match_conforming_face_pairs`` between what was labelled +"bottom" (perp=y) and "top" (also a swapped label) did the +geometric mismatch surface: nonmortar centroid at (0.125, 0.0) in the +(x, z) plane has z_mean=0, which can only happen if all 4 z-coords +are 0 — a degenerate quad on the bottom face, which is impossible. + +The fix +------- +``BoundaryClassifier3D._discover_face_label_by_attr`` is called +at __init__ time. For each boundary attribute present on the +mesh, it inspects one parent boundary element with that +attribute, determines which axis is invariant (zero spread) and +at which extreme (matching ``bbox_min`` or ``bbox_max``), and +maps (axis, extreme) to the canonical label via +``_AXIS_EXTREME_TO_LABEL``. The discovered mapping is stored as +``self._face_label_by_attr`` and used by all downstream methods. + +Detection guarantees +-------------------- +- If the mesh isn't axis-aligned (no axis is invariant within + ``self.tol``), discovery raises explicitly. +- If two attributes map to the same label (e.g., both attribute + 1 and attribute 4 land on ``y_min``), discovery raises. +- If discovery doesn't find an element for every attribute in + ``[1, n_attrs]``, discovery raises. + +Lesson generalised +------------------ +**Don't hardcode index-to-meaning mappings that depend on FE +library internals.** MFEM's element-type ordering (e.g., which +local face is "face 0" for a hex), boundary attribute ordering, +and DOF orderings (byNODES vs byVDIM) are all conventions that +shift between versions and configurations. Discover the mapping +from actual mesh data when correctness depends on it. The cost +is one extra setup pass at init time; the benefit is robustness +to upstream changes that would otherwise produce silent +correctness bugs (face elements assigned to wrong faces but +right counts, etc.). + +### §11.7.3 What is (and isn't) in C's nullspace + +A subtle question that surfaced during Phase 3.3.C macOS validation +and is worth pinning down: **the constant displacement field is +NOT in C's nullspace** (in the wirebasket-hierarchy formulation we +use), even though "u_nonmortar = u_mortar at every matched pair" is +trivially satisfied by a constant. + +Why constants leak +------------------ +The mortar block partition-of-unity `D[k] = Σ_l A_m[k, l]` holds +when both sides are summed over **all** mortar nodes — corner + +edge + interior. But the constraint matrix C is built with **corner +and box-edge mortars dropped via sentinels** (the wirebasket +hierarchy of §5.4). The dropped contributions don't appear in the +A_m sum, but they DO appear in D[k] (which is computed from the +nonmortar measure alone, independent of mortar sentinels). So: + + D[k] - Σ_kept A_m[k, l] = ∫ M_k · N_dropped_mortar ≠ 0 + +For a nonmortar node k near a box corner, the corner mortar node's N +function has support there, and the corresponding A_m entry that +"would have been" at column corner_mortar is dropped by the +sentinel filter. Result: row k has a partition-of-unity defect of +order J/2 (half the corner-element Jacobian). + +Why this is correct +------------------- +The defect is exactly compensated in the saddle-point system by +the **explicit Dirichlet prescription on corner DOFs**. Phase 1B's +2D driver (and the upcoming Phase 3.4 3D driver) prescribes: + + u_corner = u_lin(X_corner) = (F-I) X_corner (locked) + +When the saddle-point right-hand side is built as +``b_constraint = -C_corner · u_corner_prescribed``, the +partition-of-unity defect becomes a constraint forcing term that +correctly drives the nonmortar DOFs to track the mortar modulo the +imposed corner values. A constant field has u_corner = constant, +which IS what the constraint enforces — but only if you account +for the corner column contribution explicitly in the RHS, NOT by +asking C·u_const = 0. + +What IS in C's nullspace +------------------------ +**Periodic fluctuations that vanish at corners.** A function like +``sin(2π X/L) sin(2π Y/L) sin(2π Z/L)`` (or any product where each +factor vanishes at X=0 and X=L) is: + + 1. zero at every box corner / box edge / box face boundary + (so all sentinel-affected DOFs are zero anyway), and + 2. periodic with period L, so u(nonmortar_X) = u(mortar_X) for any + matched mortar-nonmortar pair on the same axis. + +Both conditions together mean C · u = 0 exactly. This is the right +"nullspace probe" for testing C: build a periodic-vanishing-at- +corners field, multiply by C, expect machine-zero residual. + +Lesson for Phase 3.4 driver implementation +------------------------------------------- +The 3D end-to-end driver must compute the constraint RHS as the +**non-zero macroscopic-jump term** including corner contributions. +A naive `b = 0` would converge u_tilde to a wrong solution (one +where corners have arbitrary values) rather than to u_lin = +(F-I)·X. The 2D Phase 1B code already does this correctly via +``apply_linear_part`` + corner-prescribed Dirichlet; the 3D driver +mirrors the structure. + +## §11.8 The phasing plan for Phase 3 + +The plan is staged so each phase is locally testable. Hex and tet tracks +develop in parallel where convenient; some phases are element-type +agnostic. + +**Phase 3.1 — 3D mesh + linear-elastic patch test, NO mortar.** + +Hex mesh built via `mfem.Mesh.MakeCartesian3D`, OR tet mesh via +`MakeCartesian3D` with `Element.TETRAHEDRON`. **Full Dirichlet** on +all 6 boundary faces at u_lin = (F-I)X. NO periodic constraint, NO +traction. Solve linear elastic K · u = 0 with the prescribed Dirichlet +boundary; for homogeneous material, the unique solution is u = u_lin. + +**Why full-boundary Dirichlet, not corner-only.** The naïve "8 corners +pinned at u_lin, free elsewhere" formulation does NOT have u_lin as +its solution. For homogeneous linear elasticity: +- div σ(u_lin) = 0 in Ω (constant stress ⇒ zero divergence) +- σ · n ≠ 0 on ∂Ω (constant stress hits surface normal) + +Pinning corners only leaves ∂Ω\corners with the natural BC σ · n = 0, +which is incompatible with the constant-stress field. The minimum- +energy solver then returns a non-affine field that satisfies σ · n = +0 on the free boundary; ‖du‖_∞ comes back at the percent level, not +machine precision. The free-Neumann mismatch is exactly the boundary +load the production-stage *mortar PBC* (Phase 3.4) supplies via +periodic nonmortar-mortar coupling — there's nothing to validate here at +Phase 3.1 about that mechanism, so we sidestep it by clamping all of +∂Ω. + +With full-boundary Dirichlet at u_lin, only interior DOFs are free, +and ∫∇N_i dV = 0 for compactly-supported interior basis functions, so +(K · u_lin)_i = 0 for all interior i. The solver drives du = 0 to +machine precision. This validates the K assembly + Dirichlet +elimination + CG-AMG solve infrastructure end-to-end, without mortar. + +This phase establishes: +- 3D mesh handling for both hex and tet. +- 3D FES (vdim = 3, byNODES ordering — see §9.4 trap). +- Boundary-TDOF discovery via `fes.GetEssentialTrueDofs(ess_bdr_all, + list)` and conversion to global TDOFs (helper: + `find_all_boundary_tdofs`). +- Full-boundary Dirichlet via `EliminateRowsCols`. +- 3D ParaView visualization (mesh-node-warped, byNODES/byVDIM robust). +- 3D `compute_volume_averaged_F` (just a dim = 3 generalisation of + the 2D one — element-type-agnostic). + +PASS criterion: ‖u − u_lin‖_∞ < 1e-10 for homogeneous uniform F on +both hex and tet RVE meshes. + +**Phase 3.2 — Dual basis + Wohlmuth modification + face-mortar assembler, pure-Python tests.** + +This phase is split into two sub-phases that develop on the same pure- +Python layer (no MFEM dependency, fully unit-testable from synthetic +data): + +**Phase 3.2.A — Dual bases and Wohlmuth modifications.** + +Build: +- `M_line2_dual` already in place (`mortar_pbc/mortar_2d.py`). +- `M_tri3_dual(λ)` — eq. 4.19. +- `M_quad4_dual(ξ, η)` — eq. 4.16. +- `M_tet4_dual(λ)` — eq. 4.21 (volume mortar; not used for face mortar + but documented for completeness). +- `M_tri3_dual_modified(λ, boundary_nodes)` — eqs. 5.5, 5.6. +- `M_quad4_dual_modified(ξ, η, side_ξ, side_η)` — eqs. 5.8, 5.10. + +Unit tests, 3D analogs of the 2D suite (one per dual basis kind): + +- `test_lumped_positivity_*`: **precondition test** — for each element + type's standard FE shape functions {N_j}, verify s_j = ∫_E N_j > 0 + by direct quadrature on the reference element (one test per type: + line-2, line-3, tri-3, tri-6, quad-4, quad-8, quad-9, tet-4). Per + the §4.9.1 lumped-positivity criterion, this is the O(1) acceptance + test for whether strict bi-orthogonality is even attemptable on the + element. Expected outcome: PASS for line-2, line-3, tri-3, tet-4, + quad-4, quad-9; FAIL with s_corner = 0 for tri-6, tet-10; FAIL with + s_corner < 0 for quad-8, hex-20. The failing cases route to §4.10 + (basis-transformation) or §4.11 (LOR) at higher-order roadmap time. + At Phase 3.2 we only implement the PASS-list dual bases, but this + test guards against silently shipping a broken dual when a new + element type is added later. +- `test_dual_basis_biorthogonality_*`: ∫ M_i N_j = δ_ij ∫ N_j (one + test per element type currently in scope). +- `test_dual_basis_partition_of_unity_*`: ∑_i M_i = 1 (one test per + type). +- `test_wohlmuth_quad4_modification`: edge-adjacent and corner-adjacent + modifications preserve partition of unity. +- `test_wohlmuth_tri3_modification`: 1- and 2-vertex-dropped + modifications preserve partition of unity. + +**Status: COMPLETE.** `mortar_pbc/mortar_3d.py` ships all of the +above; `tests/test_mortar_3d_unit.py` covers all listed tests; all +pass. + +**Phase 3.2.B — Face-mortar assembler for conforming face pairs.** + +Bridge layer between the per-element dual bases of 3.2.A and the +global constraint matrix C built in Phase 3.3. The 3D analog of +`MortarAssembler2D` — operates on pure-Python face-element data +classes (no MFEM dependency), so unit-testable with synthetic +face meshes. + +Architectural decisions, locked here so 3.3 can plug in: + +1. **`MortarFaceAssembler` ABC + concrete subclasses + `QuadFaceMortarAssembler` and `TriFaceMortarAssembler`** per §11.9 + Q7. The base class carries the assembly LOOP (nonmortar-element + iteration, quadrature, accumulation into D and A^m); subclasses + provide element-type-specific kernels (`_eval_nonmortar_dual`, + `_eval_nonmortar_shape`, `_eval_mortar_shape`, `_quadrature_pts_wts`, + `_nonmortar_jacobian`). + +2. **Element data classes** `QuadFaceElement` and `TriFaceElement` + (in `mortar_pbc/types_3d.py`) hold: + - `coords`: (n_nodes, 3) physical coords of face-element corners + in CCW order viewed from the *outward* normal of the nonmortar face. + - `gtdofs`: list of n_nodes ints — global TDOFs of the *primary* + spatial component, with sentinel **−1 for corner DOFs** and + **−2 for edge DOFs** (these rows are dropped by the wirebasket + hierarchy of §5.4). Vector-valued constraint construction in 3.3 + expands `gtdofs[i]` to per-component TDOFs via the FES ordering. + - `parametric_axes`: tuple of two axis labels ("x"/"y"/"z") that + parametrize the face plane. + - `perpendicular_axis`: axis label of the face normal. + - `boundary_tag`: per-edge classification of the element ("none", + "edge-X", "corner-XY", …) used by the assembler to choose the + correct Wohlmuth-modified dual. + +3. **Conforming-pair path is the only Phase 3.2.B scope.** The + assembler accepts a list of pre-matched `(nonmortar_elem_idx, + mortar_elem_idx, mortar_node_perm)` tuples plus the nonmortar/mortar + element lists. Mortar-node-permutation handles the case where the + mortar-side face-element's local node ordering is shifted/reflected + relative to nonmortar-side; for axis-aligned `MakeCartesian3D` meshes + the permutation is the identity, but the API supports general + conforming pairings to keep Phase 3.5 a drop-in extension. + +4. **`match_conforming_face_pairs(nonmortar_elems, mortar_elems, + perpendicular_axis, period)`** helper, pure-Python, uses + parametric centroids + a tolerance-based KD-tree-style spatial + index to pair up nonmortar/mortar elements. Returns the + `(nonmortar_idx, mortar_idx, mortar_node_perm)` list. For axis-aligned + `MakeCartesian3D` it's a single-pass match; for misaligned but + conforming meshes it handles permutations. + +5. **Sentinel-row drop policy.** Rows of D and A^m corresponding to + nonmortar-side gtdofs −1 (corner) or −2 (edge) are dropped *during* + assembly: the assembler simply doesn't accumulate into those rows. + This matches the 2D pattern (`MortarAssembler2D` drops rows for + corner sentinels) and the §5.4 wirebasket hierarchy. + +Unit tests, validating the above on synthetic data (no MFEM): + +- `test_face_mortar_quad_single_elem_conforming`: one quad-4 nonmortar + paired with one quad-4 mortar, no boundary modification. Verify + D = A^m = (|E|/4) · I_4 (eq. 3.8 conforming-pair lumping). +- `test_face_mortar_quad_2x2_grid_conforming`: 2×2 quad grid on each + face. Verify D and A^m are 4×4 diagonal with correct per-node + Jacobian-weighted lumping. +- `test_face_mortar_tri_single_elem_conforming`: tri-3 nonmortar/mortar + pair, no modification. Verify D = A^m = (|T|/3) · I_3. +- `test_face_mortar_quad_with_edge_sentinel_drop`: nonmortar with one + edge-sentinel gtdof = −2. Verify the corresponding row of D and + A^m is absent / zero (depending on sentinel-drop policy chosen). +- `test_face_mortar_quad_with_corner_modification`: nonmortar element + adjacent to a face corner uses `M_quad4_dual_modified` with + appropriate `corner-XY` tag. Verify A^m off-diagonal coupling + emerges and partition-of-unity row sums (∑_l A^m[k,l] over + *non-sentinel* mortar nodes) match the modified dual's expected + integrals. +- `test_face_mortar_tri_with_one_vertex_dropped`: equivalent for + tri-3. +- `test_lumped_positivity_guard`: the assembler's __init__ runs + `lumped_positivity()` against its own `_eval_nonmortar_shape` on the + reference element and raises if any s_j ≤ 0. Verify this catches a + hypothetical mis-instantiation with a tri-6 dual basis. + +The test file is `tests/test_face_mortar_3d.py`; it runs in the +sandbox without MFEM. + +**Phase 3.3 — `BoundaryClassifier3D` + `ConstraintBuilder3D`.** + +This phase is split into four sub-phases. 3.3.A is a small dim- +genericity refactor that lets the existing 2D edge-mortar machinery +be reused for 3D edge pairs; 3.3.B builds the boundary classifier +on a single ParSubMesh primitive; 3.3.C composes the per-element- +type and per-feature blocks into the global constraint matrix; 3.3.D +is the first integration test (sparsity-only; full patch test is 3.4). + +**Phase 3.3.A — Generalise `MortarAssembler2D` for 3D edge coordinates.** + +The 2D edge-mortar math (1D parametric integration with line-2 dual +basis and Wohlmuth corner modification) is dimension-agnostic. The +only 2D-specific code is the axis-lookup in `_param_endpoints`: + +```python +axis = 0 if edge.parametric_axis == "x" else 1 # 2D-only +``` + +The fix is a one-line dictionary lookup that supports `"z"` too: + +```python +axis = {"x": 0, "y": 1, "z": 2}[edge.parametric_axis] +``` + +After this change, `MortarAssembler2D._assemble_pair` operates on +any duck-typed edge with `parametric_axis ∈ {"x", "y", "z"}`, +`edge_min`/`edge_max`, `coords[node_idx, axis]`, and an `elements` +list of `(node1, node2)` tuples with corner sentinels. `EdgeInfo3D` +satisfies all of these. The downstream `gtdofs` plumbing differs +between 2D and 3D, but the assembler doesn't touch gtdofs — only +the constraint builder consumes them. + +Verification target: a unit test that takes a synthetic `EdgeInfo3D` +pair (along the z-axis at fixed x, y), runs `MortarAssembler2D +._assemble_pair`, and verifies the lumping recovery (D = A_m = +diag(per-segment Jacobian) on a conforming pair). + +**Phase 3.3.B — `BoundaryClassifier3D` via a single boundary ParSubMesh.** + +Architectural decision (locked): one `ParSubMesh` of the entire +boundary, not one per face attribute. Rationale: + +1. **Unified back-mapping.** A single submesh-to-parent mapping + covers face-elements, edges, and corners. We don't manage 6 + separate face-submeshes plus 12 edge-data structures plus + 8 corner records, each with its own parent-mapping concern. +2. **Wirebasket classification falls out structurally.** On an + axis-aligned box: + - submesh vertex touches **3** distinct parent boundary + attributes ⇒ corner (8 of them) + - submesh edge has **2** distinct parent attributes adjacent ⇒ + box edge (12 of them, 4 per direction) + - submesh element has **1** parent boundary attribute ⇒ face + interior element (6 face groups) + The classification is one walk over submesh elements, accumulating + per-vertex sets of parent boundary attributes. +3. **Forward-compatible with the §4.11 LOR fallback.** A single + refined submesh suffices for higher-order LM construction; we + don't re-architect for that future at Phase 6+. + +ParSubMesh-to-parent API used: + +- `mfem.ParSubMesh.CreateFromBoundary(parent_pmesh, attrs_array)` — + builds the submesh. +- `submesh.GetParentElementIDMap()` — `Array` of parent + boundary-element indices per submesh element. +- `submesh.GetParentVertexIDMap()` — `Array` of parent vertex + indices per submesh vertex. +- `pmesh.GetBdrAttribute(parent_bdr_id)` — face-attribute lookup on + the parent boundary element. +- `parent_fes.GetVertexDofs(parent_vert_id)` and the standard + `local_dof → global_tdof` chain — for getting parent TDOFs at any + submesh vertex. + +For order-1 H1 (Phase 3 scope), DOFs live at vertices, so the +vertex-id map is sufficient for full TDOF back-mapping. Higher-order +(Phase 6+) requires walking edge/face interior DOFs too; the §4.11 +LOR fallback obviates that for our use case. + +The classifier output: +- `corners: Dict[str, CornerInfo3D]` — 8 corner records with parent + global TDOFs. +- `edges: List[EdgeInfo3D]` — 12 edges, each with parent global + TDOFs and the line-2 connectivity needed by `MortarAssembler2D`. +- `faces: List[FaceInfo3D]` — 6 faces, each with a list of + `QuadFaceElement` or `TriFaceElement` (or both, for mixed + hex+tet meshes — the boundary submesh's `GetGeometryType()` + per element discriminates). + +The classifier interface is cleanly separable from the underlying +MFEM ParSubMesh: it produces pure-Python data classes that +downstream `ConstraintBuilder3D` and the existing Phase 3.2.B +assemblers can consume without holding a ParSubMesh reference. + +**Phase 3.3.C — `ConstraintBuilder3D`.** + +Takes the classifier output and produces global C as a CSR matrix +(replicated, scipy-style, mirroring 2D `ConstraintBuilder2D`). +For each periodic group: + +- **Edge mortar blocks (9 total)**: 3 directions × 3 mortar-nonmortar + pairs each (1 mortar + 3 parallel nonmortars per direction). Each + block built via the Phase-3.3.A-generalised `MortarAssembler2D + ._assemble_pair(mortar_edge, nonmortar_edge)`. Wohlmuth corner + modification handled by the existing `_corner_side` mechanism; + corner-DOF rows dropped via the existing sentinel pattern. +- **Face mortar blocks (3 total)**: 3 mortar-nonmortar face pairs. + Each face-element list passed to the appropriate Phase-3.2.B + assembler (`QuadFaceMortarAssembler` or `TriFaceMortarAssembler`, + dispatched per face element via geometry type; mixed-element + faces accumulate from both assemblers and row-stack). Wohlmuth + modification via `boundary_tag` on each face element; corner- + and edge-DOF rows dropped via the sentinel pattern. + +All blocks stacked via the existing `stack_constraints` machinery +into one CSR C. The constraint builder is a pure-Python +orchestrator — no MFEM dependency beyond what the classifier +already brought in. This keeps the C-assembly side of the saddle +point cleanly portable to a custom C++ class for ExaConstit +(important because MFEM has no `MixedNonlinearForm` analogue to +its `MixedBilinearForm`, so the C++ port will assemble C directly +into a `HypreParMatrix` rather than via MFEM's mixed-form +machinery). + +**Phase 3.3.D — Sparsity-only integration test.** + +Build the full pipeline (classifier → assemblers → C) on an +axis-aligned `MakeCartesian3D` hex RVE and a tet RVE, both 4×4×4. +Verify: +- C has the expected row count: (n_edge_DOFs × 3 components) + + (n_face_DOFs × 3 components), with corner / edge crosspoints + removed by the wirebasket hierarchy. +- C·u = 0 for an affine field u = (F-I)X (constraint is satisfied + exactly by any field that's affine across the periodic boundary; + this is the linear-field reproduction property of the dual basis). +- Symmetry of mortar coupling under mortar/nonmortar swap (sanity + check; mortar formulation is asymmetric by design but the + swap should produce a valid block too). + +This phase does NOT solve the saddle-point system — that's 3.4. +This phase verifies C alone. + +**Phase 3.4 — End-to-end 3D patch test driver.** + +Hex AND tet RVE with conforming mesh on opposite faces, linear elastic +Method-D plus mortar PBC, multi-step ramp, ParaView output, ⟨F⟩ +diagnostic, SciPy direct cross-check. PASS criteria identical to 2D: +Krylov converges, constraint residual at machine precision, Krylov vs. +direct match, ⟨F⟩ = F_macro to ~1e-13, fluctuation non-trivial in +heterogeneous case. + +Test layouts: +- Homogeneous hex cube (sanity, both element types): u_tilde = 0. +- 3D analog of strip-split (hex track): half x ≤ L/2 stiff, half compliant. +- 3D analog of strip-split (tet track): same, on a tet mesh. +- 3D analog of checkerboard (hex track): 8-octant XOR pattern. +- 3D analog of checkerboard (tet track): same on tet mesh. +- **Mixed-element test (highest correctness bar)**: half hex, half tet. + +**Phase 3.5 — Non-conforming face pairs.** + +Add the geometric face-to-face polygon clipping (Sutherland-Hodgman, see +§3.7 pseudocode). Mesh different refinements on opposite faces: e.g., +y=0 face has 4×4 quads, y=L face has 6×6 quads of slightly rotated +orientation. Re-run the patch test suite. Since the linear-elastic / +mortar formulation doesn't change, this is purely a geometric +extension of the nonmortar-quadrature-to-mortar-coordinate matching. + +This is the phase where Tribol [LLNL Tribol] *might* become attractive +as an alternative backend for the polygon-clipping piece. Defer +evaluation until 3.4 is solid; hand-rolling Sutherland-Hodgman for +convex-on-convex (our case for quad-on-quad axis-aligned faces, also +fine for tri-on-tri and mixed cases) is straightforward and +dependency-free. + +## §11.9 Open Phase-3 design questions + +These are decisions that need an answer (or are at least flagged) before +Phase 3.3 starts. The recommendations are mine; finalise after a pass +through this doc. + +1. **Constraint storage layout.** In 2D, C is replicated on every rank. In + 3D for moderate RVE sizes the same approach works: + + - 64×64×64 cube RVE: 6 faces × ~64×64 face-DOFs/face = ~24k face LM rows. + Plus 12 edges × ~64 edge-DOFs/edge = ~770 edge LM rows. Per spatial + component (×3): ~74k total rows. NNZ per row is ≤ 8 (nonmortar + mortar 4-node-quad + coupling). Storage: 74k × 8 × 8 bytes = 4.7 MB per rank. **Replicated + across ranks at this scale is fine.** + + - For larger RVEs (256×256×256 or above) we'd want distributed C. The + existing operator-only design supports it — just need a distributed + row-partition aware version of `WeightedRowSqSum`. + + **Recommendation: stay replicated for Phase 3, migrate later if needed.** + +2. **Reference vs spatial configuration for mortar integration.** For our + total-Lagrangian convention (§9), all assembly uses the reference + configuration. ExaConstit's "updated-Lagrangian-at-load-step" model + doesn't change the per-step kinematics: the reference geometry doesn't + actually move. Mortar C is built once per mesh-change event. For nonlinear + materials with K = ∂F_int/∂u, K changes per Newton iterate but C does not. + + **Recommendation: build C once, on the reference configuration, when the + mesh and material are set. Re-build only on mesh adaptation events. Confirmed.** + +3. **Dual basis integration order.** The integrand depends on element + class: + + - **quad-4 unmodified**: the dual basis is bilinear in (ξ, η), the FE + basis is bilinear, and ∫ M_i N_j is biquadratic — order 2 + Gauss-Legendre quadrature (4 points = 2×2) handles it exactly. + - **quad-4 corner-modified** (eq. 5.10): the dual basis is constant + (= 1) on the modified element. Integration against bilinear N is + trivially bilinear; 1×1 quadrature suffices. + - **tri-3 unmodified**: dual basis (eq. 4.19) is linear in λ_i; FE + basis is linear. ∫ M_i N_j is quadratic in barycentric + coordinates. Dunavant's 3-point rule [Dunavant 1985] of degree 2 + is exact. + - **tri-3 edge-adjacent modified** (eq. 5.5): dual basis is linear + (constant + linear); ∫ M^mod N is still quadratic. 3-point + Dunavant. + - **tri-3 corner-adjacent modified** (eq. 5.6): dual basis is + constant. ∫ const N is linear; 1-point centroid rule suffices. + - **line-2 unmodified**: integrand is quadratic; 2-point Gauss + suffices. + - **line-2 modified**: integrand is linear; 1-point suffices. + + **Recommendation: use a uniform "safe" rule per element type + (4-point Gauss for quad, 3-point Dunavant for tri, 2-point Gauss for + line-2) across all elements regardless of modification status. The + theoretical reduction of order on modified elements gives at most a + ~20% speedup that doesn't matter at prototype scale and is fragile + (a missed corner case integrates wrong). Optimise only if + profiling shows it matters.** + +4. **Polygon clipping for non-conforming face pairs (Phase 3.5).** + Sutherland-Hodgman [Sutherland & Hodgman 1974] is simple enough to + hand-roll for convex-on-convex polygons: + + - **Quad-on-quad** (axis-aligned hex pairs): trivial, 4-on-4. + - **Tri-on-tri** (axis-aligned tet pairs): same algorithm, 3-on-3. + - **Mixed** (quad nonmortar on tri mortar, or vice versa): same + algorithm; clip the nonmortar (3 or 4 vertices) against the mortar + (3 or 4 vertices). + + `shapely` has the algorithm but is a heavy dependency. Tribol [LLNL + Tribol] has industrial-strength clipping for contact mechanics; we + may evaluate Tribol's API in Phase 3.5 as an alternative. + + **Recommendation: hand-roll Sutherland-Hodgman in Phase 3.5 + (~150 lines of Python, dependency-free); defer non-conforming + testing until conforming Phase 3.4 is solid. Re-evaluate Tribol + only if hand-rolled clipping proves unstable for skewed faces.** + +5. **3D mesh source.** Five mesh types in scope: + - (a) Pure hex via `mfem.Mesh.MakeCartesian3D`. + - (b) Pure tet via `MakeCartesian3D` + `Mesh::ConvertToTets()`, + OR by reading a tet `.mesh` file. + - (c) Mixed hex + tet (read from external mesh files; MFEM + supports mixed-element meshes natively). + - (d) Non-conforming hex (independent face refinement; build via a + `build_nonconforming_cube` analog of the existing + `build_nonconforming_square`). + - (e) Non-conforming tet (analogous). + + **Recommendation: (a) and (b) for phases 3.1–3.4, plus (c) for the + mixed-element correctness test in 3.4. (d) and (e) for phase 3.5. + Defer non-conforming until conforming is solid.** + +6. **Edge LM grouping.** Per-direction (4 edges per direction, 3 mortar + pairs per direction → 9 total mortar groups) versus per-edge-pair? + The latter means 12 separate mortar groups (each pair of + "topologically equivalent" edges). The implementation can go either + way. + + **Recommendation: per-direction grouping. Each direction has 4 + parallel edges; pick one mortar, couple the other 3. + 3 directions × 1 mortar × 3 nonmortar-couplings = 9 sub-blocks; stack + them into one C block per direction.** + +7. **Element-type dispatch for face mortar.** The polymorphic + `MortarFaceAssembler` interface (§11.4) handles quad-4 and tri-3 + uniformly. The C++ port will use virtual dispatch on + `mfem::Element::Type`. For Python, dispatch on + `element.GetGeometryType()` returning `mfem.Geometry.SQUARE` vs + `mfem.Geometry.TRIANGLE`. + + **Recommendation: dispatch on `element.GetGeometryType()`. Build + `QuadFaceMortarAssembler` and `TriFaceMortarAssembler` as concrete + subclasses of a common `MortarFaceAssembler` ABC; let + `ConstraintBuilder3D` dispatch per face element.** + +8. **Higher-order primal field.** ExaConstit's primary FE order is + p = 1 for crystal plasticity, but if/when p ≥ 2 enters the roadmap, + the design question is: implement the §4.10 Popp-Wohlmuth-Gee-Wall + higher-order dual basis from scratch (per element type), or use the + §4.11 lower-order projection (LOR) fallback? + + **Recommendation: defer to Phase 6+; when needed, use LOR + linear + dual + Barbosa-Hughes stabilisation per §4.12.** This re-uses the + §4.2–§4.5 linear dual machinery, requires only a uniformly-refined + ParSubMesh and one new stabilisation integrator, and matches Tribol's + established design philosophy. The full higher-order dual basis is + a multi-month effort with no precedent in the CPFEM-homogenisation + literature; LOR is the pragmatic middle ground. + +--- + +# §12. Hard-won lessons (the trap list) + +This is the most important section of the document. Each trap below cost +real time. Future work should re-read this list before each new feature. + +## §12.1 Discrete-correctness traps + +**Trap 1. Use K_full to compute RHS in Method D, not K_eliminated.** + +Symptom: free DOFs move in the *opposite* direction of u_lin in the +visualization. Corners are correct. + +Diagnosis: `K_eliminated · u_lin` zeros out the K_uc · u_lin[corner] term at +free rows, but for the affine field to be the equilibrium under affine-corner +BC, that term must be present (it's the K_uu · u_lin[free] balancer). Without +it, the saddle-point solve drives u toward something ≠ u_lin to "fix" a +spurious residual. + +Solution: assemble K twice (`K_full`, `K_eliminated`); use `K_full` for the +RHS computation `f = K_full · u_lin`; zero corner entries of `f` by hand; +use `K_eliminated` for the saddle-point top block. + +In code: `MortarPbcDriver2D.__init__` takes both `K_op` (eliminated) and +`K_op_full` (un-eliminated). `_solve_independently` uses `K_op_full.Mult` for +the RHS. SciPy direct cross-check uses `K_full_global_csr` for its RHS too. + +Per MFEM issue #793: `a.ParallelAssemble()` may share `SparseMatrix` data +with the `ParBilinearForm`. To get truly independent K_full and K_eliminated, +build *two independent* `ParBilinearForm` objects and assemble each +separately. + +**Trap 2. The Wohlmuth corner modification is not optional.** + +Symptom: in 2D, the patch test fails for shear F or any F that places the +corner-LM redundancy into a numerical contradiction. Krylov may diverge or +the constraint residual may stagnate. + +Diagnosis: without dual-basis modification at corner-adjacent nonmortar segments, +the corner LM rows are redundant with the corner Dirichlet BCs. The +discrete C is rank-deficient. + +Solution: implement `M_line2_dual_modified(xi, side)` per Lopes Eq. C.2, +drop corner-LM rows from the constraint block during assembly, and verify +via a unit test (`test_wohlmuth_crosspoint_modification`). + +In 3D, this generalizes: corners dropped from edges (1D Wohlmuth), edges +dropped from faces (2D Wohlmuth on quad-4). See §11. + +**Trap 3. The Newton residual must include the C^T · λ contribution.** + +Symptom: ||F_int||_2 stagnates at the natural force scale of the problem +(e.g. ~1e5 for our 5× contrast neo-Hookean test) regardless of how +converged the actual equilibrium is. Newton appears to fail. + +Diagnosis: at equilibrium, F_int = −Cᵀλ, not zero. ||F_int||_2 is *NOT* the +right convergence measure. ||F_int + Cᵀλ||_2 is. + +Solution: in the Newton loop, after solving for du and dλ, accumulate +λ += dλ, and compute the next iteration's residual as +`r1 = nlf.Mult(u) + Cᵀ · λ`. Pass `r1` to the saddle-point solver AND use +`||r1||_2` as the convergence criterion. + +The verification gather block must mirror this. Naively recomputing +`nlf.Mult(x, residual)` after Newton converges and reporting that as "final +residual" is misleading — it's F_int alone, not F_int + Cᵀλ. + +**Trap 4. ParNonlinearForm handles essential DOFs internally.** + +Symptom: applying `apply_dirichlet_to_distributed_K` *after* +`nlf.GetGradient(x)` corrupts K (double-elimination). + +Diagnosis: `ParNonlinearForm.SetEssentialTrueDofs(...)` makes nlf: +- `nlf.Mult(x, residual)` returns residual with essential DOFs already zeroed. +- `nlf.GetGradient(x)` returns the tangent with essential rows/cols already + eliminated. + +Solution: only the *linear-elastic* manual driver path applies +`apply_dirichlet_to_distributed_K`. Nonlinear drivers must NOT. + +**Trap 5. Krylov stagnation from a tiny RHS.** + +Symptom: Newton declares failure, but the trace shows residual at noise +floor before max_iter. Newton "couldn't improve." + +Diagnosis: when Newton has effectively converged but the outer loop hasn't +recognised it, the next Krylov call sees a tiny RHS, exits with 0 iterations, +returns du = 0. The outer loop sees no improvement and concludes failure. + +Solution: include `||du||_2 < du_floor` as a convergence path in the Newton +outer loop, in addition to relative residual + constraint criteria. + +**Trap 6. Absolute Newton tolerance ignores problem scale.** + +Symptom: setting atol = 1e-10 is physically meaningless when the natural +force scale is 1e5. Either Newton "converges" prematurely on tolerance that +nothing physical needs to satisfy, or it never reaches that tolerance because +the noise floor is at 1e-7. + +Solution: relative-drop convergence with absolute floor as safety net for +trivially-tiny problems. `||r1||_2 < max(rtol · r0, atol)`. Choose rtol per +problem class (1e-8 typical), atol per noise floor (1e-12 conservative). + +## §12.2 MFEM / pyMFEM API traps + +**Trap 7. byNODES vs byVDIM ordering mismatch.** + +Symptom: visualization shows a 90° rotation of the deformed mesh. + +Diagnosis: `ParFiniteElementSpace(pmesh, fec, vdim=dim)` defaults to +`Ordering::byNODES`. `pmesh.SetCurvature(order)` defaults to `Ordering::byVDIM`. +Adding a byNODES displacement TDOF vector elementwise to a byVDIM mesh-node +TDOF vector silently swaps x/y components. + +Solution: explicitly pass `fes.GetOrdering()` to `SetCurvature`: + +```python +pmesh.SetCurvature(1, False, -1, fes.GetOrdering()) +``` + +The visualization helper handles this defensively now. + +**Trap 8. `nlf.GetGradient` returns `mfem::Operator&` (base class).** + +Symptom: trying to call `as_HypreParMatrix` on the return value of +`nlf.GetGradient(x)` gives an attribute error. + +Diagnosis: pyMFEM exposes only the base. The dynamic type is normally +`HypreParMatrix`, but pyMFEM's SWIG wrapper doesn't downcast automatically. + +Solution: use `mfem.Opr2HypreParMat` (the explicit downcast helper) or +duck-type-check `hasattr(op, "MergeDiagAndOffd")`. For verification gather +paths only — the actual saddle-point solve doesn't care about the dynamic +type, since it consumes K via `Mult` only. + +**Trap 9. `GetDataArray()` view-vs-copy ambiguity.** + +Symptom: writing into a numpy view of an `mfem.Vector` mysteriously fails to +update the underlying vector. + +Diagnosis: on some pyMFEM builds `mfem.Vector.GetDataArray()` returns a +view; on others it's a copy. The behavior depends on SWIG flags at build +time. + +Solution: use element-wise assignment via `__setitem__`: + +```python +for i in range(vec.Size()): + vec[i] = float(arr[i]) +``` + +This always works, on every pyMFEM build, on every type of vector. + +**Trap 10. `ParallelAssemble` may share data.** + +Symptom: calling `EliminateRowsCols` on a "second" HypreParMatrix corrupts +the "first" one too. + +Diagnosis: `a.ParallelAssemble()` returns a HypreParMatrix that may share +the underlying SparseMatrix with the ParBilinearForm. Calling it twice on +the same `a` is *not* guaranteed to give independent matrices. + +Solution: build two independent `ParBilinearForm` objects (with the same +integrators and FES), `Assemble()` each, `ParallelAssemble()` each. Pay the +small cost of the extra local-assembly step in exchange for guaranteed +independence. + +**Trap 11. BlockDiagonalPreconditioner doesn't own its diagonal blocks.** + +Symptom: Krylov solve produces NaN or random garbage. Stack trace shows +something about freed memory. + +Diagnosis: `mfem.BlockDiagonalPreconditioner` does NOT own the +`Operator` objects passed to `SetDiagonalBlock(i, op)`. Python GC will +collect them mid-Krylov-solve unless explicit references are kept alive +*outside* the function scope. + +Solution: `SaddlePointSolver._build_block_jacobi_prec` returns a `keepalive` +list that the caller stashes on `self._last_prec_refs`. This holds Python +references to the diagonal block objects for the duration of the solve. + +**Trap 12. NeoHookean integrator NaN at u=0.** + +Symptom: `nlf.Mult(zero_par, residual)` returns NaN throughout (except at +essential DOFs which are 0). + +Diagnosis: pyMFEM's `NeoHookeanModel(mu_coef, K_coef)` constructor (and all +variants tested) has a numerical issue at u=0 in this build of pyMFEM. +We pivoted to linear-elastic for the prototype. + +Solution: linear-elastic `ElasticityIntegrator` works fine. For the eventual +production port, write a custom integrator subclass or use a different MFEM +build. Diagnostic preserved at `examples/diag_neohookean_2x2.py`. + +## §12.3 MPI traps + +**Trap 13. Every collective must run on every rank.** + +Symptom: deadlocks at np > 1, especially after rank-0-only print blocks. + +Diagnosis: a `comm.allreduce`, `C_op.Mult`, or `BoundaryClassifier2D` +construction inside a `if rank == 0:` block (or under any rank-asymmetric +guard like `if n_lam_local > 0:`) means rank 0 enters the collective and +other ranks don't, deadlocking. + +Solution: never wrap a collective in a rank-asymmetric guard. If you need +a print-only block, separate the collective from the print: + +```python +# WRONG: +if rank == 0: + val = comm.allreduce(local, op=MPI.SUM) # deadlock + print(val) + +# RIGHT: +val = comm.allreduce(local, op=MPI.SUM) # everyone enters +if rank == 0: + print(val) +``` + +**Trap 14. MPI gather requires consistent vector sizes.** + +Symptom: rank 0 receives a flat-array but its content is misaligned to the +contributing ranks' partitions. + +Diagnosis: `comm.Gatherv` uses `counts` and `displs` arrays. If the per-rank +vector sizes were computed with a different convention than the gather +expects, the displacement array will be wrong. + +Solution: always gather sizes via an `allgather(my_size)` first, then +compute displs via `cumsum(counts[:-1])` *with `prepend=0`*. Don't try to +infer counts from the FES partition — use what the actual local data +provides. + +## §12.4 Visualization / total-Lagrangian discipline traps + +**Trap 15. Mesh-node mutation persists across visualisation calls.** + +Symptom: in multi-step driver, step k's u_lin is "more stretched" than +expected by ~1% or more (depending on step and k). The cross-check fails +by similar magnitude. + +Diagnosis: the visualization writer warps the mesh to deformed configuration +and saves; without restoring to reference, the next call to +`apply_linear_part(fes, F^{n+1})` evaluates `(F^{n+1} − I)·X` against the +*deformed* nodes, not the reference. This compounds over multiple steps. + +Solution: `PbcVisualizationWriter.write_step` resets the mesh to the +reference snapshot *after* saving each cycle. The writer is now side-effect- +free with respect to the mesh; every operation outside the writer always +sees the reference. See §9. + +This is the **total-Lagrangian discipline** — implementations are responsible +for keeping the mesh on the reference configuration unless visualisation is +explicitly active. + +**Trap 16. ⟨F⟩ matches F_macro for the wrong reason.** + +Symptom: even when the implementation has Trap-15-style bugs (deformed +reference frame), the ⟨F⟩ diagnostic reports F_macro to machine precision. + +Diagnosis: when both `apply_linear_part` and `compute_volume_averaged_F` +read from the *same* deformed mesh state, they are mutually consistent — +the homogenization average theorem still says ⟨∇ũ⟩ = 0 because that's a +*property of periodicity*, not of the particular reference frame. The +diagnostic measures internal consistency, not correctness against the +reference frame. + +Solution: enforce reference-frame discipline (see Trap 15); separately +verify via SciPy direct cross-check on rank 0 using ALL operators from the +reference-frame state. The cross-check catches reference-frame mismatch +*if and only if* the K matrices in it are reference-frame and the gathered +u_lin is also reference-frame. + +In our prototype: K is assembled once at init (reference-frame), and after +applying Trap-15 fix, all subsequent operations use reference-frame +quantities. Verification block now succeeds at machine precision. + +## §12.5 Process / debugging traps + +**Trap 17. Trust the unit tests; don't trust the patch test.** + +The unit tests verify *math properties* of pieces (dual basis bi-orthogonality, +partition of unity, Wohlmuth modification correctness). They are direct +statements about isolated math. + +The patch test (homogeneous RVE → ũ = 0) is a *derived consequence* of: +- Correct math → correct mortar assembly → correct constraint → correct + saddle-point system → correct linear solve → patch test passes. + +If a unit test fails, you know exactly where the bug is. If the patch test +fails, you only know *something* in that chain is wrong. + +When debugging, fix the unit tests first. When developing a new piece, write +the unit test first. + +**Trap 18. Verify on conforming AND non-conforming.** + +A conforming-only test passes even if your A_m matrix has a sign error, +because the diagonality of D papers over the issue. Non-conforming exposes +the asymmetry of the dual basis. + +The 2D unit test `test_nonconforming_pair_consistency` exists for this. The +3D extension will need a `test_nonconforming_face_pair_consistency` that +linear-projects against the standard dual / N basis. + +**Trap 19. Verify on heterogeneous AND homogeneous.** + +A homogeneous-only test passes even if your constraint matrix has a sign error, +because ũ = 0 and the constraint is trivially satisfied. Heterogeneous +material guarantees a non-trivial fluctuation that the constraint actually +needs to enforce. + +The 2D heterogeneous strip-split and checkerboard layouts are this check. +The 3D test suite needs a 3D analog (heterogeneous octant pattern, see +§11.7 Phase 3.4). + +--- + +# §13. C++ port pathway into ExaConstit + +This is the production target. The 2D prototype, the in-progress 3D extension, +and eventually the C++ rewrite all go into ExaConstit's framework. This +section tells future readers what the port looks like. + +> **For the actual implementation plan, see `PHASE4_CPP_PORT_PLAN.md`.** +> This section provides the high-level class sketch and the integration- +> with-ExaConstit-internals story (§13.3, §13.4, §13.5). The companion +> doc `PHASE4_CPP_PORT_PLAN.md` provides the per-component implementation +> specifics, phasing, hazards, and done criteria — i.e. it's the working +> document for the port itself. This section stays as the conceptual +> overview; the companion doc is the project plan. + +## §13.1 What pyMFEM has taught us about MFEM C++ + +The translation table: + +| pyMFEM (prototype) | MFEM C++ (port) | +|---|---| +| `mfem.par.ParFiniteElementSpace` | `mfem::ParFiniteElementSpace` | +| `mfem.par.ParBilinearForm` | `mfem::ParBilinearForm` | +| `mfem.par.HypreParMatrix` | `mfem::HypreParMatrix` | +| `mfem.par.GMRESSolver` | `mfem::GMRESSolver` | +| `mfem.par.BlockOperator` | `mfem::BlockOperator` | +| `mfem.par.BlockDiagonalPreconditioner` | `mfem::BlockDiagonalPreconditioner` | +| `mfem.par.IntegrationRules.Get(...)` | `mfem::IntegrationRules::Get(...)` | +| Python `PyOperatorBase` subclass | C++ `mfem::Operator` subclass | +| Python ABC `ConstraintAssembler` | C++ pure-virtual interface | + +The pyMFEM API is essentially a 1:1 wrapper of MFEM C++, so the prototype's +class structures translate directly. The places where pyMFEM-specific quirks +needed defensive coding (Trap 9, Trap 10) collapse to non-issues in C++. + +## §13.2 The class design in C++ + +Following Lopes' and our prototype's structure, the C++ port has: + +```cpp +namespace exaconstit { namespace mortar_pbc { + +// 2D and 3D variants of the boundary classifier. +class BoundaryClassifier2D { ... }; +class BoundaryClassifier3D { ... }; + +// Pure-virtual constraint assembler interface. +class ConstraintAssembler { +public: + virtual void Assemble(...) = 0; + virtual int NumLocalRows() const = 0; + virtual void Mult(const mfem::Vector& x, mfem::Vector& y) const = 0; + virtual void MultTranspose(const mfem::Vector& x, mfem::Vector& y) const = 0; + virtual ~ConstraintAssembler() = default; +}; + +// Concrete subclass for mortar PBC. +class MortarPbcConstraintAssembler : public ConstraintAssembler { ... }; + +// (Future) Concrete subclass for uniform traction. +// class UniformTractionConstraintAssembler : public ConstraintAssembler { ... }; + +// Stack multiple assemblers into one combined constraint operator. +std::unique_ptr StackConstraints( + std::vector> assemblers); + +// Saddle-point solver. Subclass of mfem::ConstrainedSolver. +class MortarPbcSchurSolver : public mfem::ConstrainedSolver { ... }; + +// Multi-step driver, mirrors MortarPbcDriver2D. +class MortarPbcDriver { ... }; + +}} +``` + +The `MortarPbcSchurSolver` class is a candidate **upstream MFEM contribution**: +MFEM's `mfem/linalg/constraints.hpp` already provides +`SchurConstrainedHypreSolver`, `EliminationCGSolver`, and +`PenaltyConstrainedSolver`, but all three require an assembled +`HypreParMatrix` K. None handle the matrix-free / PA-K / GPU-friendly case. +Our `MortarPbcSchurSolver` *is* that variant. After ExaConstit integration is +solid, propose upstream as a fourth subclass. + +## §13.3 Hooks into existing ExaConstit infrastructure + +ExaConstit's existing framework provides: + +- `BCManager`: handles essential BCs by attribute. PBC is constraint-based, + not essential-BC-based, so we either extend BCManager with a constraint-aware + variant or add a sibling `ConstraintManager` class. Recommendation: sibling. + +- `mech_operator`: ExaConstit's wrapper around `ParNonlinearForm` (or its + PA-friendly equivalent). Provides the K-as-Operator that our saddle-point + solver consumes. No changes needed — already PA-friendly. + +- `SystemDriver::SolveInit`: the warm-start projection. Already implements + the "linear projection of BC change through previous-step tangent" pattern + (§7). Needs extension to handle PBC's saddle-point version (the projection + is itself a saddle-point solve when constraints are active). + +- `BCManager::ComputeBCDelta`: the place that computes the change in essential + values between steps. For displacement-driven PBC, this becomes + `(F^{n+1} − F^n)·X[corner]`. Needs adapter. + +The `MortarPbcDriver2D` (and eventually 3D) maps to a new ExaConstit class, +say `MortarPbcSystemDriver`, that wraps `SystemDriver` and adds the +constraint-assembly + saddle-point-solve responsibilities. + +## §13.4 The PA path requirement + +Critical architectural constraint, baked in since Phase 1A: + +- **K is always treated as `mfem::Operator` only.** Never `tocsr()`, never + `As()`, never gathered. +- The block-Jacobi preconditioner uses only `Operator::AssembleDiagonal`, + which works uniformly across PA, EA, FA, and HypreParMatrix forms. + +This is the GPU-portability requirement: in PA mode, K is matrix-free, lives +on GPU, and never produces a CSR. Anything that requires CSR access is a +no-go for the production solver. The block-Jacobi + Krylov path is correct +for any K-form; HypreBoomerAMG (a more sophisticated prec) is FA-only and +would need replacement with a matrix-free multigrid in PA mode. + +For the prototype's saddle-point solver, the C operator is built as a Python +wrapper around a scipy CSR (replicated per rank). This is fine for +prototype-scale. In C++ we'll re-implement C as a true `mfem::Operator` that +applies the mortar coupling matrix-free or via a small distributed CSR. + +## §13.5 What goes upstream and what stays in ExaConstit + +**Goes upstream (potential MFEM contribution):** +- `MortarPbcSchurSolver`: a fourth `ConstrainedSolver` subclass, matrix-free + K-friendly, block-Jacobi prec. + +**Stays in ExaConstit:** +- `MortarPbcConstraintAssembler` and the surrounding `ConstraintAssembler` + ABC: domain-specific to the RVE-PBC application. Fine in `exaconstit::mortar_pbc::`. +- `BoundaryClassifier2D/3D`: similar, fine in ExaConstit. +- `MortarPbcDriver`: a thin orchestration layer; ExaConstit-specific. + +The rule of thumb: if it's reusable across applications (not just RVE +homogenization), it goes upstream. If it's RVE-specific, it stays. + +--- + +# §14. Open questions and forward plan + +This section is the working agenda. Items are tagged by priority. + +## §14.1 Immediate (Phase 3, in priority order) + +- [ ] **Phase 3.1**: 3D linear-elastic patch test, NO mortar. Establish 3D + mesh / FES / Dirichlet / visualization scaffolding. +- [ ] **Phase 3.2**: Quad-4 dual basis + Wohlmuth modification, pure-Python + unit tests. ~5 new unit tests. No MFEM coupling required. +- [ ] **Phase 3.3**: `BoundaryClassifier3D` + `ConstraintBuilder3D`. Integrates + Phase 3.2 output into the constraint-assembly machinery. Conforming + meshes only. +- [ ] **Phase 3.4**: End-to-end 3D patch test driver. PASS criteria identical + to 2D, plus three new test layouts (homogeneous, octant strip-split, + octant 8-XOR). +- [ ] **Phase 3.5**: Non-conforming face pairs via Sutherland-Hodgman. + +## §14.2 Medium-term (Phase 4-5) + +- [ ] **Phase 4 — C++ port (standalone in `tests/mortar_pbc/`)**: + Detailed plan in `PHASE4_CPP_PORT_PLAN.md`. Three rounds: + Phase 4.1 initial port with AllGather + HypreParMatrix C; + Phase 4.2 distributed-hash matching to scale beyond ~500 ranks; + Phase 4.3 element-assembly C operator for GPU portability. + Validation against the validated Python prototype's three test + drivers (homogeneous, heterogeneous strip-split, checkerboard + octant-XOR). Does NOT touch ExaConstit production code paths; + lives entirely in `tests/mortar_pbc/`. +- [ ] **Phase 5 — ExaConstit integration**: Once Phase 4 is green and + promoted to `src/mortar_pbc/`, integrate with `BCManager`, + `SystemDriver::SolveInit`, the velocity-primal switch (§7.1 + and §13.3 cover the interface points). This is a separate + planning conversation. +- [ ] **Upstream MFEM contribution**: propose `MortarPbcSchurSolver` (or a + more general matrix-free constrained solver) as a fourth + `ConstrainedSolver` subclass. After Phase 4.3 is solid (the EA + path is what makes it matrix-free). + +## §14.3 Long-term (Phase 6+) + +- [ ] **Multi-step driver with proper warm-start handling for nonlinear K**: + the `MortarPbcDriver2D.solve_next_step` recipe is documented; needs + Newton outer loop reactivation when nonlinear material is available. +- [ ] **Velocity-based primal formulation**: rate-dependent crystal plasticity + wants this. Maps cleanly to ExaConstit's existing primal. +- [ ] **Tribol integration as an alternative `ConstraintAssembler`**: for + contact and general non-conforming geometry beyond axis-aligned RVEs. +- [ ] **Uniform Traction (UT) BCs as a second `ConstraintAssembler`**: UT + was the original motivation for the ConstraintAssembler ABC; now it's + a matter of writing one new subclass and stacking it. +- [ ] **Higher-order primal field (p ≥ 2)**: see §4.8–§4.12 for the dual + basis theory and the recommended LOR + linear dual + Barbosa-Hughes + stabilisation pathway. Triggered if/when ExaConstit adopts p = 2 hex + / quad-9 / tri-6 / tet-10 elements for crystal plasticity. Tribol's + LOR mechanics (§4.11.4) provides the precedent in the LLNL/MFEM + ecosystem. + +## §14.4 Open design questions (require explicit answers) + +These are flagged in §11.9 with recommendations; finalise them before Phase +3.3 starts. + +1. Constraint storage: replicated per-rank in 3D? **Recommendation: yes, + migrate to distributed only if memory pressures require it.** +2. Reference vs spatial mortar integration? **Recommendation: reference, + build C once per mesh-change.** +3. Dual basis integration order? **Recommendation: 2nd-order Gauss + quadrature (4 points/quad), reduce to 1st-order on Wohlmuth-modified + elements only if profiling shows the savings matter.** +4. Polygon clipping library or hand-roll for non-conforming faces? + **Recommendation: hand-roll Sutherland-Hodgman in Phase 3.5.** +5. 3D mesh source? **Recommendation: `MakeCartesian3D` + face-independent + refinement extension (`build_nonconforming_cube`) for testing; + conforming-only for Phases 3.1-3.4.** +6. Edge LM grouping per-direction or per-pair? **Recommendation: + per-direction (3 sub-blocks per direction, mortar + 3 nonmortars; total 9 + edge-mortar sub-blocks).** +7. Element-type dispatch for face mortar? **Recommendation: dispatch on + `element.GetGeometryType()`; `QuadFaceMortarAssembler` and + `TriFaceMortarAssembler` as concrete subclasses.** +8. Higher-order primal field handling (p ≥ 2)? + **Recommendation: defer to Phase 6+; when needed, use LOR + linear + dual + Barbosa-Hughes stabilisation per §4.12.** Avoid the per-element- + type basis-transformation route unless homogenisation accuracy + demands it. + +--- + +# §15. References + +## §15.1 Primary references + +1. **Lopes, I. A. R.; Ferreira, B. P.; Andrade Pires, F. M.** (2021). *On the + efficient enforcement of uniform traction and mortar periodic boundary + conditions in computational homogenisation.* Computer Methods in Applied + Mechanics and Engineering, **384**, 113930. DOI: 10.1016/j.cma.2021.113930. + + Primary reference for our formulation. Method D (line 342, Remark 1), + corner essentials (lines 1034–1035), Wohlmuth crosspoint modification + (Appendix C, equations C.1–C.3). Local copy: + `/mnt/user-data/uploads/1-s2_0-S004578252100267X-main.pdf` (in original + conversation environment). + +2. **Wohlmuth, B. I.** (2000). *A mortar finite element method using dual + spaces for the Lagrange multiplier.* SIAM Journal on Numerical Analysis, + **38**(3), 989–1012. + + Foundation paper for the dual-basis mortar method. Crosspoint + modification originally from this paper. + +3. **Wohlmuth, B. I.** (2001). *Discretization Methods and Iterative + Solvers Based on Domain Decomposition.* Lecture Notes in Computational + Science and Engineering, vol. 17. Springer. + + Book-length development of the mortar / dual-basis method. + +## §15.2 Computational homogenization references + +4. **Miehe, C.** (2003). *Computational micro-to-macro transitions for + discretized micro-structures of heterogeneous materials at finite + strains based on the minimization of averaged incremental energy.* + Computer Methods in Applied Mechanics and Engineering, **192**, 559–591. + + Canonical reference for displacement-fluctuation-based PBC formulation; + the "Lopes/Miehe school" of PBC. Method D in our terminology corresponds + to Miehe's formulation. + +5. **Geers, M. G. D.; Kouznetsova, V. G.; Brekelmans, W. A. M.** (2010). + *Multi-scale computational homogenization: Trends and challenges.* + Journal of Computational and Applied Mathematics, **234**, 2175–2182. + + Survey paper. Useful for context on the broader homogenization + landscape. + +## §15.3 ExaConstit and tooling + +6. **ExaConstit GitHub**: https://github.com/llnl/ExaConstit + - `src/system_driver.cpp:441-478` (`SolveInit`). + - `src/fem_operators/mechanics_operator.cpp:295-331` (`GetUpdateBCsAction`). + - Issue #8: discussion of time-evolving BCs and the warm-start rationale. + +7. **MFEM**: https://github.com/mfem/mfem + - `mfem/linalg/constraints.hpp`: `ConstrainedSolver` ABC and three + existing subclasses (Schur/Elim/Penalty). + - Issue #793: shared-data behavior of `ParBilinearForm::ParallelAssemble` + (relevant to Trap 10). + +8. **pyMFEM**: https://github.com/mfem/pyMFEM + - Commit pinned to `7e99b925cfcbec002c9e21230b3c561cb19436a6` + (MFEM 4.9 build fixes). + +9. **Tribol**: https://github.com/llnl/Tribol + - LLNL contact / mortar library. May be relevant as backend for Phase 3.5 + non-conforming geometric matching. + +## §15.4 Related supporting references + +10. **Sutherland, I. E.; Hodgman, G. W.** (1974). *Reentrant polygon clipping.* + Communications of the ACM, **17**(1), 32–42. + DOI: 10.1145/360767.360802. + + Basic polygon clipping algorithm; relevant for Phase 3.5 face mortar + geometric matching. Cited in §3.7 and §11.9. + +11. **Bernardi, C.; Maday, Y.; Patera, A. T.** (1994). *A new + nonconforming approach to domain decomposition: The mortar element + method.* In: Brezis, H.; Lions, J.-L. (eds.) Nonlinear Partial + Differential Equations and their Applications. Collège de France + Seminar, Vol. XI. Pitman, pp. 13–51. + + Original (standard, non-dual) mortar method. Cited in §3.4 and §4.7. + +12. **Hill, R.** (1972). *On constitutive macro-variables for + heterogeneous solids at finite strain.* Proceedings of the Royal + Society A, **326**(1565), 131–147. + DOI: 10.1098/rspa.1972.0001. + + Hill-Mandel principle, average theorem. Cited in §8.1. + +13. **Mandel, J.** (1972). *Plasticité Classique et Viscoplasticité.* + CISM Courses and Lectures No. 97. Springer, Wien. + + Companion of [Hill 1972] for the macro-micro stress-strain + averaging theorem in finite-strain plasticity. Cited in §8.1. + +14. **Lamichhane, B. P.; Wohlmuth, B. I.** (2007). *Higher order mortar + finite element methods in 3D with dual Lagrange multiplier bases.* + Numerische Mathematik, **107**(1), 151–170. + DOI: 10.1007/s00211-005-0636-z. + + Provides dual Lagrange multiplier bases for higher-order tetrahedral + and serendipity-hexahedral elements; the linear-tet formula M_i = + 5 λ_i − 1 (eq. 4.21 in this doc) appears as their Theorem 3.4 + special case. Cited in §4.4, §4.5, §4.8, §5. + +15. **Popp, A.; Wohlmuth, B. I.; Gee, M. W.; Wall, W. A.** (2012). + *Dual quadratic mortar finite element methods for 3D finite + deformation contact.* SIAM Journal on Scientific Computing, + **34**(4), B421–B446. + DOI: 10.1137/110848190. + + Construction of feasible dual Lagrange multiplier spaces for + higher-order interface elements (6-node tri, 8/9-node quad). Source + of the basis-transformation procedure for higher-order biorthogonal + bases. Cited in §4.8. + +16. **Strang, G.; Fix, G. J.** (1973). *An Analysis of the Finite + Element Method.* Prentice-Hall. + + Standard FE textbook; source for simplex integration formulas + (eqs. 4.7a–c in this doc). Cited in §4.1. + +17. **Dunavant, D. A.** (1985). *High degree efficient symmetrical + Gaussian quadrature rules for the triangle.* International Journal + for Numerical Methods in Engineering, **21**(6), 1129–1148. + DOI: 10.1002/nme.1620210612. + + Triangle quadrature rules used in the tri-3 face mortar + integration (§11.3). The 3-point degree-2 rule is the default for + Phase 3.2. Cited in §11.3 and §11.9. + +18. **Flemisch, B.; Wohlmuth, B. I.** (2007). *Stable Lagrange + multipliers for quadrilateral meshes of curved interfaces in 3D.* + Computer Methods in Applied Mechanics and Engineering, **196**(8), + 1589–1602. + + Detailed treatment of dual basis on 3D curved interfaces; relevant + for future extensions beyond axis-aligned cubes. + +## §15.5 Higher-order dual mortar references + +19. **Lamichhane, B. P.; Wohlmuth, B. I.** (2002). *Higher order dual + Lagrange multiplier spaces for mortar finite element + discretizations.* Calcolo, **39**(4), 219–237. + DOI: 10.1007/s100920200010. + + Original construction of strict bi-orthogonal dual basis for + quadratic line elements (line-3, eq. 4.25 in this doc) and the + quartic correction for continuity at crosspoints. Cited in §4.8. + +20. **Popp, A.; Wohlmuth, B. I.; Gee, M. W.; Wall, W. A.** (2012). + *Dual quadratic mortar finite element methods for 3D finite + deformation contact.* SIAM Journal on Scientific Computing, + **34**(4), B421–B446. DOI: 10.1137/110848190. + + The basis-transformation procedure for tri-6, quad-8, quad-9, hex-20. + Eqs. 4.34–4.36 in this doc reproduce the explicit transformation + matrices. Production reference for BACI/4C, MOOSE. + Cited in §4.10. (Also listed as #15 above for §4.8 historical + citation; this entry is the canonical reference for the + transformation procedure.) + +21. **Wohlmuth, B. I.; Popp, A.; Gee, M. W.; Wall, W. A.** (2012). + *An abstract framework for a priori estimates for contact + problems in 3D with quadratic finite elements.* Computational + Mechanics, **49**, 735–747. DOI: 10.1007/s00466-012-0704-z. + + Convergence theory for the §4.10 basis-transformation construction; + proves O(h^p) energy / O(h^{p+1}) L² rates for quadratic dual + mortar. Cited in §4.10.4. + +22. **Lamichhane, B. P.; Stevenson, R. P.; Wohlmuth, B. I.** (2005). + *Higher order mortar finite element methods in 3D with dual + Lagrange multiplier bases.* Numerische Mathematik, **102**(1), + 93–121. DOI: 10.1007/s00211-005-0636-z. + + The "quasi-dual" relaxation: dim M_h < dim W_{0,h} construction for + cubic+ tetrahedra and serendipity hex where even the feasible + construction of [Popp et al. 2012] is impractical. Cited in §4.9.4. + (Note: this is the same DOI as ref #14, which is the publication of + the same work — distinct citations because the LSW05 framework + proper is the *prelimiary* technical machinery developed in the + full Numer. Math. paper. We cite the LSW05 form when discussing + the quasi-dual relaxation, the LW07 form when discussing higher- + order tet/hex feasible duals.) + +23. **Lamichhane, B. P.; Wohlmuth, B. I.** (2004). *A quasi-dual + Lagrange multiplier space for serendipity mortar finite elements + in 3D.* M2AN: Mathematical Modelling and Numerical Analysis, + **38**(1), 73–92. DOI: 10.1051/m2an:2004004. + + Treats the quad-8 / hex-20 serendipity case where corner lumped + integrals are *negative*. Cited in §4.9.2. + +24. **Oswald, P.; Wohlmuth, B. I.** (2001). *On polynomial + reproduction of dual FE bases.* Proc. Domain Decomposition + Methods 13, pp. 85–96. + + The Gauss-Lobatto theorem: full P_{p−1} polynomial reproduction + of dual basis on tensor-product elements holds *iff* nodes are + Gauss-Lobatto-spaced. Cited in §4.9.3. + +25. **Brivadis, E.; Buffa, A.; Wohlmuth, B. I.; Wunderlich, L.** + (2015). *Isogeometric mortar methods.* Computer Methods in + Applied Mechanics and Engineering, **284**, 292–319. + DOI: 10.1016/j.cma.2014.09.012. + + Establishes that "the p/(p−1) pairing is numerically unstable" + in the unmodified mortar formulation, motivating either Belgacem + cross-point modification, or LOR + stabilisation. Cited in §4.11.3. + +26. **Wunderlich, L.; Seitz, A.; Alaydin, M. D.; Wohlmuth, B. I.; + Popp, A.** (2019). *Biorthogonal splines for optimal weak + patch-coupling in isogeometric analysis with applications to + finite deformation elasticity.* Computer Methods in Applied + Mechanics and Engineering, **346**, 197–224. + arXiv:1806.11535. + + IGA dual mortar with B-splines; relevant for the parametric- + integration treatment of curvilinear interfaces. Cited in §4.9.3. + +27. **Acharya, B. S.; Patel, A.** (2019). *Convergence results with + natural norms: Stabilized Lagrange multiplier method for elliptic + interface problems.* arXiv:1705.10519. + + Barbosa-Hughes-type stabilisation that recovers quasi-optimal + rates for non-stable LM pairings (including LOR). Cited in §4.11.3. + +28. **Gustafsson, T.; Råback, P.; Videman, J.** (2022). *Mortaring + for linear elasticity using mixed and stabilized finite elements.* + Computer Methods in Applied Mechanics and Engineering, **404**, + 115795. DOI: 10.1016/j.cma.2022.115795. arXiv:2209.02418. + + Modern treatment of Barbosa-Hughes stabilised mortar applied to + elasticity; closest to the LOR + stabilisation construction + recommended in §4.11.3 / §4.12 for ExaConstit higher-order PBC. + +29. **Pazner, W.; Kolev, T.** (2021). *Low-order preconditioning of + high-order finite element problems.* SIAM Journal on Scientific + Computing, **43**(6), A4032–A4055. DOI: 10.1137/20M1364643. + + Theory of LOR (low-order refinement); the geometric property + (4.38) — Lagrange-node / refinement-vertex coincidence — is + Theorem 2.1 of this paper. Foundation for the §4.11.1 + construction. + +30. **Chin, E.** (2023). *Contact constraint enforcement using the + Tribol interface physics library.* MFEM Workshop 2023, + https://mfem.org/pdf/workshop23/19_Chin_Tribol.pdf. + + Documents Tribol's design choice to project high-order primal + fields onto a low-order-refined contact mesh — the precedent in + the LLNL/MFEM ecosystem cited in §4.12. + +--- + +End of MORTAR_PBC_ARCHITECTURE.md. + +This document should be re-read at the start of each major work session. +When new bugs are encountered, add them to §12. When new architectural +decisions are made, add them to §11 or §13. When a question in §14 is +answered, move it to a "decided" subsection or remove it. + diff --git a/experimental/mortar_pbc_proto/docs/PHASE4_CPP_PORT_PLAN.md b/experimental/mortar_pbc_proto/docs/PHASE4_CPP_PORT_PLAN.md new file mode 100644 index 0000000..7b9bad6 --- /dev/null +++ b/experimental/mortar_pbc_proto/docs/PHASE4_CPP_PORT_PLAN.md @@ -0,0 +1,4772 @@ +# Phase 4 — C++ Port Plan: Mortar PBC Standalone in ExaConstit `tests/mortar_pbc/` + +> Companion to `MORTAR_PBC_ARCHITECTURE.md`. This document is the +> implementation plan for porting the Python prototype to C++, in +> ExaConstit's `tests/mortar_pbc/` initially, then promoted to +> `src/mortar_pbc/` once validated. +> +> **Cross-references**: This document references the top-level architecture +> doc by section number throughout. When a section reference appears +> (e.g. §11.7.2), it points to the architecture doc. When a sub-section of +> THIS document is referenced, it appears as §P4.X.Y. +> +> **Loading this document into a fresh conversation**: Pair this file +> with `MORTAR_PBC_ARCHITECTURE.md` (the "architecture doc") and any current +> Python prototype source. Together they are sufficient context to +> resume the port from any phase boundary without re-deriving prior +> decisions. + +--- + +## §P4.1 Goals and non-goals + +### Goals +1. Port the validated Python 3D mortar-PBC prototype (homogeneous + + heterogeneous strip-split + 2x2x2 octant checkerboard tests) to + C++ with the **same numerical answers** at np=1, np=4, np=16, hex + and tet, both linear-elastic with PBC corner-Dirichlet. +2. Use ExaConstit's existing infrastructure where it exists (Caliper, + `mech_operator`, MFEM operator hierarchy) without re-inventing. +3. Validate scaling characteristics through a deliberate progression + (np=4 → np=16 → np=256 → np=1024) BEFORE attempting integration + into the production solver. +4. Ship a CPU+GPU-capable code path where MFEM K-action is GPU-resident + and constraint operations follow MFEM's GPU-aware operator interface. +5. Set up the architecture so the eventual move to velocity-based + primal (for ExaConstit integration) is a focused change to one + class (`MortarPbcDriver`). + +### Non-goals (explicitly deferred) +- **Full ExaConstit integration**: not part of Phase 4. After Phase 4, + Phase 5 handles `BCManager` ↔ `ConstraintManager` adapter, + `SystemDriver::SolveInit` extension to handle saddle-point projection, + and the velocity-primal switch. +- **Non-conforming face matching (Sutherland-Hodgman)**: still a + Python-prototype Phase 3.5 task. The C++ port handles only conforming + faces in Phase 4. +- **Tribol integration as an alternative `ConstraintAssembler`**: long- + term, see architecture doc §14.3. +- **Higher-order primal (p ≥ 2)**: long-term, see architecture doc §4.12. +- **Hypre + GPU**: not yet supported by MFEM for vector-dimension + problems (see §P4.4.1). CPU Hypre + GPU MFEM K-action is the Phase 4 + target; Hypre+GPU enabled later as upstream MFEM matures. + +--- + +## §P4.2 Architectural overview + +Four independently testable components, identical in structure to the +Python prototype but with the scalability/portability constraints baked in: + +``` +┌────────────────────────────────────────────────────────────────────┐ +│ BoundaryClassifier3D │ +│ Setup-time only. Inspects ParMesh + ParFES, produces topology: │ +│ 8 corners, 12 edges, 6 faces, with sentinel-tagged face/edge │ +│ elements. Mirrors Python boundary_3d.py. │ +│ Constructed ONLY on boundary ranks (boundary_comm; §P4.4.0). │ +│ Setup MPI: AllGather (Phase 1) → tile-partitioned matching │ +│ (Phase 2), both on boundary_comm. │ +└────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────────────┐ +│ MortarAssembler2D / FaceMortarAssembler3D │ +│ CPU-only integration kernels. Per-pair dense D, A_m blocks │ +│ via Gauss quadrature on dual-modified bases. No MPI, no shared │ +│ state. Wholly templated on element vertex count (3 or 4) for │ +│ static dispatch. │ +│ Mirrors Python mortar_2d.py + face_mortar_3d.py. │ +└────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────────────┐ +│ ConstraintBuilder3D │ +│ Constructed ONLY on boundary ranks; assembles row contributions │ +│ on boundary_comm. │ +│ Phase 1: builds local-row contributions, INSTALLS into a │ +│ distributed mfem::HypreParMatrix C on WORLD with empty │ +│ row blocks for interior ranks (§P4.4.5). │ +│ Phase 2: refactor to AllGather-free distributed matching │ +│ (the §P4.4.4 work). │ +│ Phase 3: optional EA path — keeps per-element local D, A_m and │ +│ implements Mult / MultTranspose without ever forming │ +│ a CSR (matrix-free C, GPU-friendly). │ +│ Mirrors Python constraint_builder_3d.py. │ +└────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────────────┐ +│ MortarPbcDriver │ +│ Multi-step ramping driver. Owns persistent state (u, λ, F_n). │ +│ Wraps mfem::BlockOperator + saddle-point Krylov solve │ +│ (MINRES default; GMRES, BiCGStab also supported; §P4.4.7). │ +│ Constructs and owns the boundary subcommunicator at startup. │ +│ Mirrors Python multistep_driver.py. │ +└────────────────────────────────────────────────────────────────────┘ +``` + +This layering matches §13.2 of the architecture doc but expanded into +implementation detail. The dependency arrow goes downward only; +each layer is unit-testable against the Python output without +involving the layers above. + +--- + +## §P4.3 Three-pronged C++ ratchet + +The port proceeds in three independent rounds; each round is a +"ratchet click" that locks in a property and does not regress. + +### Round 1 (Phase 4.1) — Initial port, AllGather-based, HypreParMatrix C +- All four classes implemented at "works correctly at np=4" quality. +- Constraint matrix C is a `mfem::HypreParMatrix`, built by gathering + global topology to every rank (mirrors Python prototype exactly). +- K is whatever MFEM gives us (CPU-FA or GPU-EA via existing + `assemble_linear_elastic_K`-equivalent). +- All three test drivers (homogeneous, heterogeneous strip-split, + checkerboard) ported and passing at np=1, 4, 16. + +### Round 2 (Phase 4.2) — Distribute the boundary topology +- Replace the AllGather pattern in `BoundaryClassifier3D` with + a distributed-pair matching scheme based on 2D tile partitioning + of the parametric plane (§P4.4.4). +- No change to the public API of any class. +- Validation: same three drivers pass at np=4 and now at np=256, 1024. +- This unlocks the path to scale; Phase 4.1 caps somewhere near + np=500–1000 depending on memory. + +### Round 3 (Phase 4.3) — Element-assembly C alternative +- Add an EA-style `MortarConstraintOperator` that holds per-pair + local D and A_m blocks, implements `Mult` / `MultTranspose` via + per-pair scatter-gather, never forms a CSR. +- Selectable via runtime flag: `--constraint-storage=hypre` (default) + vs `--constraint-storage=ea`. +- Validation: identical numerical output to the HypreParMatrix path + to within Krylov tolerance. +- This is the GPU-friendly path — once it works, it's the production + default. + +The order matters: Round 1 establishes correctness, Round 2 establishes +scale, Round 3 establishes performance. **Don't touch Round N+1 until +Round N is fully green.** + +--- + +## §P4.4 Per-component design specifics + +### §P4.4.0 MPI communicator strategy: the boundary subcommunicator + +#### The premise: not every rank touches the boundary + +In a domain-decomposed RVE problem on a roughly-cubic grid, only the +ranks whose subdomain touches the outer boundary have boundary work +to do. With nranks ≈ p³ ranks in a p×p×p arrangement, the boundary +ranks are those on the outer faces of the rank grid — total +``6p² - 12p + 8`` for a cube. As p grows this becomes a vanishing +fraction of all ranks: + +| nranks (p×p×p) | boundary ranks | boundary fraction | +|----------------:|-------------------:|------------------:| +| 8 (2×2×2) | 8 | 100 % (degenerate) | +| 64 (4×4×4) | 56 | 88 % | +| 512 (8×8×8) | 296 | 58 % | +|1024 (~10×10×10) | 488 | 48 % | +|4096 (~16×16×16) | 1352 | 33 % | +|32768 (32×32×32) | ~5800 | 18 % | + +At 32 768 ranks, a WORLD AllGather-everything-to-everywhere wastes +roughly 5/6ths of the bandwidth on ranks that have nothing to +contribute and nothing to do with the result. Worse, **interior +ranks must still participate** in any WORLD collective even though +they own zero boundary records — every WORLD AllGather syncs them +unnecessarily and turns "work that should be free for them" into +synchronization cost. + +This isn't fixed by the Phase 4.2 distributed-pair-matching +refactor — it's a separate, easier improvement that should be in +from Round 1. + +#### The fix: boundary subcommunicator from MPI_Comm_split + +At driver startup, BEFORE constructing the classifier, the driver +splits WORLD into "ranks-with-boundary" + "ranks-without-boundary": + +```cpp +int has_boundary = (pmesh.GetNBE() > 0) ? 1 : 0; + +MPI_Comm boundary_comm = MPI_COMM_NULL; +MPI_Comm_split(MPI_COMM_WORLD, + has_boundary ? 1 : MPI_UNDEFINED, + world_rank, + &boundary_comm); +// boundary_comm is MPI_COMM_NULL on interior ranks (color = MPI_UNDEFINED). +// On boundary ranks it's a fresh communicator with consecutive ranks +// 0..n_boundary_ranks-1. + +// Sanity-check: must have at least 8 ranks for the 8 corners. +if (boundary_comm != MPI_COMM_NULL) { + int n_bdy_ranks; MPI_Comm_size(boundary_comm, &n_bdy_ranks); + MFEM_VERIFY(n_bdy_ranks >= 1, "Empty boundary communicator"); +} +``` + +The classifier and constraint builder accept `boundary_comm` as a +constructor arg. On interior ranks (where `boundary_comm` is +`MPI_COMM_NULL`), neither object is constructed at all — the +driver branches on the comm and skips that whole code path. + +#### What runs on which communicator + +| Operation | Communicator | +|----------------------------------------------|----------------| +| Bounding box reduction | WORLD | +| K assembly | WORLD | +| K matvec (Krylov inner) | WORLD | +| Volume-averaged F | WORLD | +| Vector inner products inside Krylov | WORLD | +| BoundaryClassifier3D setup | boundary_comm | +| MortarAssembler integrations | (per-pair, no MPI) | +| Runtime attribute-discovery cross-check | boundary_comm | +| AllGather of boundary records (Phase 4.1) | boundary_comm | +| Distributed-hash matching (Phase 4.2) | boundary_comm | +| C HypreParMatrix construction | WORLD (with empty rows on interior ranks; see §P4.4.5) | +| C matvec / C^T matvec | WORLD (Hypre handles empty-rank rows) | + +**Why the bbox stays on WORLD**: a non-boundary rank may still own +mesh vertices (interior vertices of its subdomain) that contribute +to the bbox extent. The bbox is a property of the mesh, not the +boundary, so WORLD is correct. + +**Why C lives on WORLD even though it's "boundary-only" data**: K +lives on WORLD (volume work). The Krylov solver applies the block +operator `[K, C^T; C, 0]`. For Hypre's `BlockOperator` to mix K and +C cleanly, both must be defined on the same communicator. Putting +C on WORLD is the cleanest way; the cost is one zero-row block per +interior rank in HypreParMatrix's data structures, which is +negligible (kilobyte-scale). + +**The construction-time vs runtime distinction**: setup-side C +ASSEMBLY happens entirely on `boundary_comm` (every byte of dense +D and A_m blocks lives only on boundary ranks), but the resulting +HypreParMatrix is INSTALLED into a WORLD-shaped object via Hypre's +CSR-construct constructor with `row_starts[r] == row_starts[r+1]` +on interior ranks. No data is moved during the install step; +interior ranks just register that they own zero rows. + +#### What this changes in the classifier code + +In Python, every place that says `comm = self.pmesh.GetComm()` would +become, in C++, `comm = boundary_comm`. The bbox helpers that need +WORLD are passed it explicitly. Inside the classifier methods, +`MPI_Allgatherv` operates on the small subcomm — fewer ranks to sync +with, smaller per-message deserialization overhead, naturally less +bandwidth. + +This also affects the **"discover face-label by attribute"** +cross-rank consistency check (mortar §11.7.2). The Python version +AllGathers on WORLD; in C++ it AllGathers on `boundary_comm`. An +interior rank that doesn't have any boundary attributes shouldn't +participate in a check that asks "do all ranks see attribute 1 +on the same axis?" — only ranks that actually see boundary should. + +#### Sanity-checking the subcomm at construction + +Before the classifier does any work, sanity-check the subcomm: + +```cpp +int n_bdy_ranks_local; MPI_Comm_size(boundary_comm, &n_bdy_ranks_local); +HYPRE_BigInt n_bdr_elements_global = pmesh.GetGlobalNBE(); +MFEM_VERIFY(n_bdr_elements_global > 0, + "BoundaryClassifier3D: parent ParMesh has no global boundary " + "elements; mortar PBC is meaningless."); +// Every rank in boundary_comm should report n_local_bdr > 0. +int my_n_bdr = pmesh.GetNBE(); +MFEM_VERIFY(my_n_bdr > 0, "Rank in boundary_comm has no local boundary " + "elements; the split was constructed incorrectly."); +``` + +#### Off-rank scaling ratio (Round 1 vs Round 2) + +For comparison, here's the per-rank message volume during boundary- +record exchange under each scheme. Boundary record ~ 64 bytes +(snap-key triple + attribute + gtdofs). + +For an n=128 RVE (~2M zones) with nranks=4096 (16×16×16): + +| Phase | ranks involved | boundary verts global | per-rank send | per-rank recv | +|-------|---------------:|----------------------:|--------------:|--------------:| +| 4.1 (boundary-subcomm AllGather) | 1352 of 4096 | 100k | 5 KB | 6.7 MB | +| 4.2 (boundary-subcomm tile partitioning) | 1352 of 4096 | 100k | 5 KB | 5 KB | +| (worst case: 4.1 on WORLD AllGather) | 4096 of 4096 | 100k | 1.6 KB | 6.7 MB | + +The `4.1 boundary-subcomm` row is what we want for Round 1. +Per-rank recv volume (6.7 MB) is large but tractable. Phase 4.2's +tile-partitioned matching makes recv per-rank also bounded by the +local share, which is the real scaling fix. Compared to "WORLD +AllGather" the boundary-subcomm version doesn't even reduce per- +rank recv size — but it eliminates the 2700 interior ranks from +the sync, which is what makes it strictly better-behaved than +what I had described originally. + +### §P4.4.1 GPU portability strategy + +#### Where GPU matters and where it doesn't + +**Setup-time CPU-only (no GPU):** +- `BoundaryClassifier3D`: O(boundary_size) work, runs once. Topology + inspection + integer indexing is naturally serial; CPU code is fine. +- `MortarAssembler2D` and `FaceMortarAssembler3D`: per-pair dense + integration. Could be parallelised across pairs but the pair count + is O(n²) at worst (n = cells per RVE side), totally negligible. + +**Runtime path (GPU when available):** +- K matvec: goes through the user-provided `mfem::Operator&`. If MFEM + is built with CUDA/HIP and K is a PA/EA form, K is automatically + GPU-resident. We never touch K's storage. +- C matvec / C^T matvec: this is the architectural decision in §P4.4.5. +- Krylov solver inner products: `mfem::HypreParVector` operations are + GPU-aware when MFEM is built with GPU support. +- Block-Jacobi preconditioner: `Operator::AssembleDiagonal` is GPU- + aware. + +#### The Hypre + GPU caveat + +As of Hypre 3.1 / MFEM v4.9, **Hypre+GPU full-assembly does not work +for vector-dimension problems** (see ExaConstit issue tracking; works +for scalar problems only). Until that's fixed upstream: + +- Phase 4.1 / 4.2: K is built via MFEM full assembly (`ParBilinearForm` + + `ParallelAssemble`) **on host**, with HypreParMatrix on host. GPU + acceleration of K-action waits on upstream. +- Phase 4.3 (EA constraint path) IS independently GPU-portable for the + C side. Once Hypre+GPU is fixed, K side comes online without any + changes to our code. + +In practical terms: the EA path in §P4.4.6 is the part of our work +that's GPU-future-proofed today. The HypreParMatrix path waits on +upstream MFEM/Hypre work before yielding GPU benefit on K. + +### §P4.4.2 Namespace and directory layout + +#### Build location: `tests/mortar_pbc/` + +``` +exaconstit/ +├── tests/ +│ └── mortar_pbc/ # NEW — Phase 4 +│ ├── CMakeLists.txt # Standalone CMake target, +│ │ # links against mfem + mpi +│ ├── include/ +│ │ ├── boundary_classifier_3d.hpp +│ │ ├── boundary_classifier_2d.hpp +│ │ ├── mortar_assembler_2d.hpp +│ │ ├── face_mortar_assembler_3d.hpp +│ │ ├── constraint_builder_3d.hpp +│ │ ├── mortar_pbc_driver.hpp +│ │ ├── saddle_point_solver.hpp +│ │ ├── elastic_3d_helpers.hpp +│ │ ├── visualization.hpp +│ │ └── types_3d.hpp # CornerInfo3D, EdgeInfo3D, FaceInfo3D +│ ├── src/ +│ │ └── (one .cpp per .hpp) +│ └── examples/ +│ ├── patch_test_3d_pbc.cpp # Round 1 target; mirrors +│ │ # examples/patch_test_3d_pbc.py +│ ├── patch_test_3d_heterogeneous.cpp +│ └── patch_test_3d_checkerboard.cpp +└── (existing src/ unchanged) +``` + +#### Promotion to `src/mortar_pbc/` + +Once Round 1+2+3 are validated, contents move to `src/mortar_pbc/` +with namespace `exaconstit::mortar_pbc`. The `tests/mortar_pbc/` +directory then holds only the validation drivers (linking against +the new library target). + +### §P4.4.3 Cross-rank vertex identity in C++ + +The Python prototype uses snap-coord string keys (see mortar §11.7.1). +C++ equivalent: integer-quantised triples. + +```cpp +struct SnapKey { + int64_t ix, iy, iz; + bool operator==(const SnapKey& o) const noexcept { + return ix == o.ix && iy == o.iy && iz == o.iz; + } +}; +struct SnapKeyHash { + size_t operator()(const SnapKey& k) const noexcept { + // Hash combination via FNV-1a or boost-style XOR-with-shift. + size_t h = std::hash{}(k.ix); + h ^= std::hash{}(k.iy) + 0x9e3779b9 + (h << 6) + (h >> 2); + h ^= std::hash{}(k.iz) + 0x9e3779b9 + (h << 6) + (h >> 2); + return h; + } +}; + +inline SnapKey MakeSnapKey(double x, double y, double z, double bbox_diag) { + constexpr double rel_tol = 1e-9; + const double scale = 1.0 / (bbox_diag * rel_tol); + return { + static_cast(std::lround(x * scale)), + static_cast(std::lround(y * scale)), + static_cast(std::lround(z * scale)), + }; +} +``` + +**Critical**: `bbox_diag` is computed via `MPI_Allreduce` over local +bounding boxes BEFORE any quantisation happens. Inconsistent +quantisation grain between ranks will silently produce mismatched +keys for the same physical point. + +### §P4.4.4 Boundary-record exchange: AllGather → tile-partitioned matching + +#### §P4.4.4-status What is and is not implemented in this section + +A reader wanting to understand "did the C++ port include non- +conforming face mortars?" can answer that here without trawling +the rest of the doc: + +- **Conforming face mortars**: implemented (Python prototype + `assemble_pair_conforming` ported to C++ as + `AssemblePairConforming` in `face_mortar_assembler_3d.cpp`, + Phase 4.1.A → 4.2). 1:1 element pairing by parametric centroid + match within a configurable tolerance. +- **Non-conforming face mortars (Sutherland-Hodgman polygon + clipping)**: **NOT IMPLEMENTED** in either the Python prototype + or the C++ port. The Python prototype's + `face_mortar_3d.py` docstring marks this as "Phase 3.5" future + work; the C++ port mirrors that gap exactly. The abstract base- + class structure (`MortarFaceAssembler` ABC + concrete subclasses + pattern) is in place, so a future Phase 4.X / 5.X can add an + `AssemblePairClipped` method without redesigning the framework. +- **Non-conforming edge mortars**: **implemented** (different + story — the Python 2D code had non-conforming-via-overlap- + integration from the start, and `MortarAssembler2D` in C++ + ported it: `_integrate_overlap_segment` handles intervals on + the parametric axis even when nonmortar / mortar edges have + different subdivisions). + +In practice, the validation suite (homogeneous, heterogeneous, +checkerboard patch tests) uses **conforming hex meshes on both +sides of every periodic axis pair**, so non-conforming faces +don't appear. Non-conforming edges DO appear at face boundaries +where edge subdivisions on the periodic-pair partner edge may +not line up exactly with this side's; the 2D overlap path +handles those. + +When non-conforming face support is added (target: Phase 4.X +after 4.3 / Batch S), the changes will be: + 1. New `AssemblePairClipped` method on the face-mortar + assembler ABC, implementing Sutherland-Hodgman clipping in + parametric coordinates. + 2. Replace `MatchConformingFacePairs` with a more general + "find all overlapping mortar elements per nonmortar element" + match. + 3. The constraint builder and EA operator are unaffected — they + consume `FaceMortarPairBlock` and don't care how it was + produced. + +This work happens entirely on `boundary_comm` (§P4.4.0). Interior +ranks don't participate in any of this. + +#### Phase 4.1 (initial): AllGather the boundary records + +Mirrors Python `boundary_3d._gather_boundary_records`. Each +boundary rank gathers its local boundary submesh records (face +elements + vertex records); we `MPI_Allgatherv` the packed records +**on `boundary_comm`** to every other boundary rank, then dedup +by `(parent_attr, sorted snap-keys)` to build the global topology. +Every boundary rank ends up with identical `BoundaryClassifier3D` +state. Interior ranks have no classifier instance at all. + +Cost analysis (n=128 RVE, 16×16×16 rank grid = 4096 ranks, ~1352 +boundary ranks, ~100k boundary verts globally): +- Per-boundary-rank send : ~5 KB +- Per-boundary-rank recv : ~6.7 MB +- Number of WORLD ranks not touched by this collective: 2744 (~67%) + +This is acceptable up to roughly nranks where `n_bdy_ranks ~ 1000` +(p ~ 13, total nranks ~ 2200). Beyond that, per-rank recv volume +becomes the bottleneck and Phase 4.2 is needed. + +Memory cost per boundary rank is `O(boundary_size)` regardless +of how many boundary ranks there are. Interior ranks pay zero. + +#### Phase 4.2 (refactor): distributed-pair matching + +The scaling problem: at 100M zones the boundary has ~5M vertices. +Even with the boundary subcomm cutting interior-rank cost to zero, +the per-boundary-rank recv volume is still O(boundary_size) which +saturates at ~50 MB per rank. Acceptable but not generous; the +real scaling fix is reducing per-rank recv to +O(boundary_size / n_boundary_ranks). + +There are several reasonable algorithms for this. They all share +the same core invariant — **nonmortar and mortar partners must end +up on the same rank** for local pair matching to work — but +differ in how they assign work. + +##### The four candidate strategies + +**Strategy A — Hash on parametric centroid.** For each face element, +compute `bucket = hash(axis, snap(parametric_centroid)) % n_boundary_ranks`. +Nonmortar and mortar hash identically because their parametric coords +match modulo period. AllToAll on `boundary_comm` to shuffle, do +local matching per bucket. + + - **Pro**: trivially uniform load (hash is approximately uniform). + - **Pro**: simple; no geometric reasoning required. + - **Con**: **destroys spatial locality.** Neighboring face + elements land on different ranks. The post-matching AllToAll + that moves dense D, A_m blocks to the nonmortar-DOF owner has to + move ALL the data because the matching rank is essentially + random relative to nonmortar-DOF ownership. + - **Con**: each rank's bucket can include face elements from + physically distant locations, which means interim memory needs + holding O(boundary_size / n_boundary_ranks) elements WHOSE + PHYSICAL EXTENT IS THE WHOLE BOUNDARY. This shows up in the + L2/L3 cache behaviour during local matching. + +**Strategy B — 2D regular tile partitioning.** For each periodic- +pair axis, tile the parametric plane [0, L]² into a regular +`√n_bdy × √n_bdy` grid. Each tile is owned by one boundary rank +(`tile_owner[i, j]` is a fixed map). Face elements go to the rank +whose tile contains their parametric centroid. Same matching +property: nonmortar and mortar tile identically. + + - **Pro**: **preserves spatial locality**. Neighboring face + elements land on the same rank. The rank doing the matching + is typically also the rank owning the nonmortar DOF, because + MFEM's METIS partition tends to assign physically-adjacent + boundary elements to the same rank. Post-matching AllToAll + is small (often empty for many pairs). + - **Pro**: bucket sizes are uniform when the boundary rank count + is a perfect square (or close to it); load balance is good. + - **Con**: requires the bbox AllReduce (which we have from §P4.4.0). + - **Con**: tile-count granularity is `n_bdy_ranks` ≈ 6p², so + tile resolution is `√n_bdy × √n_bdy` per axis. For p=8 that's + 24×24 tiles per axis-plane, fine. For p=2 that's 4×4 tiles + per axis-plane = 16 tiles, with only ~24 boundary ranks + available; tile-to-rank assignment is straightforward. + +**Strategy C — Per-axis flat partitioning (3 axis sub-comms).** +Split boundary ranks into three sub-sub-communicators by +periodic-pair axis. Within each, do a 1D contiguous partition +by the parametric centroid's first coord. + + - **Pro**: simpler than B (1D partition vs 2D tiling). + - **Con**: a rank that touches multiple axis-pairs (any rank on + a box edge or corner of the rank grid) belongs to multiple + sub-sub-comms. Bookkeeping is fiddly. + - **Con**: load imbalance if the RVE is non-cubic. We don't + care for the validation tests (cubic by design) but production + materials problems may have aspect-ratio'd RVEs. + - **Con**: 1D partition has worse locality than 2D tiling for + the same rank count. + +**Strategy D — Bbox-based direct lookup ("hash-free locality").** +Each boundary rank AllGathers a small per-rank bbox table (24 +doubles per rank). For each LOCAL face element on, say, the nonmortar +side of the z-pair (z = L), the rank computes its mortar-side +parametric position (z' = 0, x' = x, y' = y) and looks up which +rank's bbox contains that point. Send directly, point-to-point. + + - **Pro**: **zero global communication for the matching itself + after the bbox AllGather.** Just point-to-point messages. + - **Pro**: per-rank send/recv volume scales with the rank's + own boundary surface, which is ~O(p) for a p×p×p arrangement + — better scaling than B's O(boundary_size / n_bdy_ranks). + - **Con**: requires that MFEM's rank-bbox lookup gives an + unambiguous answer. METIS partitions are not generally axis- + aligned (rank bboxes overlap at boundaries). When a face's + mortar-side position falls in multiple ranks' bboxes, + tiebreaking is needed. False positives must be filtered by + a "not-mine" reply protocol. + - **Con**: failure mode is silent: if the bbox lookup misses + (because the partition is irregular and the mortar-side point + doesn't fall in any rank's bbox via simple containment), the + face element's pair never gets matched. We'd need a fallback + bucket-scheme for unmatched faces. + - **Con**: more complex implementation. + +##### Recommendation: Strategy B for Phase 4.2 (implemented in Batches G–N) + +For the initial Phase 4.2 implementation, **Strategy B is the +right balance of simplicity and locality**. The tile partitioning +is structurally simple (one 2D map of `tile_idx → rank`), preserves +locality, and load-balances well for the cubic RVE test cases. + +**Implementation status**: this design landed across Phase 4.2 +Batches G through N. Strategy B's tile-shuffle delivered locality +during pair matching (Batch H); the final routing step of step 8 +below — "send to nonmortar-DOF-owner AllToAllv" — landed in Batch N +with the FES-aligned row partition convention. See +§P4.4.4-history for the batch-by-batch evolution and the +intermediate stepping-stone designs that were used to keep unit +tests passing through the refactor. + +Strategy A is the simplest but the locality penalty is real and +shows up as 2× extra AllToAll volume in the post-matching step +(moving D, A_m blocks to nonmortar-DOF owners). + +Strategy C is unnecessarily fiddly given that the 1D-vs-2D +partition difference is a small constant-factor implementation +cost. + +Strategy D is the most efficient ASYMPTOTICALLY but has the most +implementation complexity and the most failure-mode risk. **It's +the right choice IF profiling Strategy B at p ~ 30 shows the +matching phase is a bottleneck**, but not before. The bbox +AllGather for D is essentially free, so we'd add it as a pre-step +to B and only switch to D-as-primary if measurements warrant it. + +##### Strategy B detailed protocol + +Once we've committed to B, the protocol on `boundary_comm` is: + +1. (Already done in §P4.4.0) bbox AllReduce on WORLD, gives + `(bbox_min, bbox_max)` available everywhere. + +2. Each boundary rank decides on a tile resolution per axis. With + `n_bdy = boundary_comm.size()` ranks and 3 axis-pairs, allocate + `n_bdy_per_axis = n_bdy / 3` ranks per axis-pair (rounded up; + imbalance is small). Within each axis-pair, choose a tile grid + `n_tiles_x × n_tiles_y` where the product matches + `n_bdy_per_axis` and the aspect ratio approximates the RVE's. + For cubic RVEs this is `√n_bdy_per_axis × √n_bdy_per_axis`. + +3. Build a deterministic tile-to-rank map. Identical on every + rank because each rank knows the bbox and `n_bdy`. This is a + compile-time table, not a communicated structure. + +4. Each boundary rank iterates its local face elements: + - Compute the parametric centroid in the (a, b) plane. + - Determine which tile it falls in. + - Determine which boundary rank owns that tile. + - Mark the face element for sending to that rank. + +5. `MPI_Alltoallv` on `boundary_comm`: shuffle face-element + records to their tile-owning ranks. Each rank receives all + face elements in its tile, organised by axis-pair. + +6. Local pair matching per tile: + - For each axis-pair, partition the received elements into + "nonmortar side" and "mortar side" by their perpendicular + coordinate. + - For each nonmortar element, find its mortar partner by parametric- + centroid match (the existing `match_conforming_face_pairs` + algorithm; works tile-locally now, no MPI). + +7. Local mortar integration per pair: the receiving rank computes + its assigned `D_nm` and `A_m` blocks. Per-pair work is local; + no further communication. + +8. Post-integration "send to nonmortar-DOF-owner" AllToAllv on + `boundary_comm`: move dense blocks to the rank that owns the + nonmortar DOF (per the nonmortar-DOF-ownership convention in §P4.4.5). + Most blocks stay on the same rank (locality preservation + pays off here); only blocks where the matching rank ≠ nonmortar + owner move. + +9. Each rank now has its row contributions for the nonmortar DOFs + it owns. HypreParMatrix construction (§P4.4.5) proceeds as + before, on WORLD with empty rows on interior ranks. + +##### Load balance and stragglers + +For small `n_bdy_ranks` (small p), the tile-count-per-axis-pair is +small and tile-rank assignment is trivial. For large p, the tile +count grows quadratically per axis and we get fine-grained +balance. + +Load imbalance concerns: +- Corner-tile ranks (those owning the 4 corners of a face) + receive corner-of-face quads, which carry sentinel-modified D_nm + and slightly more integration work (Wohlmuth-modified basis). + This is ~25% extra work, distributed over 4 corners per face × + 3 axis-pairs = 12 corner tiles per RVE. Negligible at p > 10. +- Edge-tile ranks (those owning the 4 edges of a face, excluding + the corners) similarly carry edge-of-face quads with edge + sentinel modifications. ~10% extra work, similarly distributed. +- Interior face tiles get the majority of work and are fully + symmetric. + +If profiling shows imbalance bites at scale, the fix is a +work-stealing layer on top: ranks that finish early pull pairs +from the queues of slow ranks. This is a separate optimization +to consider only if measurements warrant. + +##### Communication cost tabulation + +For the same n=128 RVE, p=16 (16³ = 4096 ranks, ~1352 boundary +ranks) example used elsewhere: + +| Strategy | bbox AllReduce | matching shuffle | nonmortar-DOF shuffle | total per-rank | +|----------|---------------:|-----------------:|------------------:|---------------:| +| Phase 4.1 (AllGather) | 0 | 6.7 MB recv | 0 (trivial) | 6.7 MB | +| Phase 4.2 A (random hash) | 192 B | ~5 KB recv | ~5 KB recv | ~10 KB | +| Phase 4.2 B (tile) | 192 B | ~5 KB recv | ~1 KB recv (locality) | ~6 KB | +| Phase 4.2 C (axis flat) | 192 B | ~5 KB recv | ~3 KB recv | ~8 KB | +| Phase 4.2 D (bbox lookup) | 192 KB (all bdy ranks' bboxes) | ~3 KB direct | 0 (already at owner) | ~195 KB | + +(Numbers are order-of-magnitude estimates.) + +Strategy B beats A by roughly 2× on per-rank volume; D beats B +on the matching shuffle but loses on the bbox AllGather. At +this scale all four are tractable, but Strategy B is simplest +to implement correctly and gives the best end-to-end behaviour +before D's complexity becomes worthwhile. + +##### When to revisit + +- If Phase 4.2 B passes scaling validation through p = 20 + (n_bdy_ranks ~ 2000), no further work needed; that's the + upper end of "interesting" scales for ExaConstit. +- If we run into communication-bound behaviour beyond p = 30, + consider Strategy D as a follow-on optimization. Caliper data + on the matching phase will tell us whether it's worth the + implementation complexity. +- The whole machinery is in `ConstraintBuilder3D` and adjacent + classes; the public API of `BoundaryClassifier3D` doesn't + change between strategies, so swapping is a focused refactor. + +##### Implementation cost + +Phase 4.2 with Strategy B: figure 600-1000 lines of new C++, +mostly in `ConstraintBuilder3D`. The tile-rank assignment table +is small (~50 lines). The AllToAllv pack/unpack is the bulky +part (~300 lines). The local matching algorithm is essentially +the same `match_conforming_face_pairs` logic that already exists +in the Python prototype, just operating on tile-local element +lists. Worth it because Phase 4.1's per-rank recv caps the +framework somewhere between p=13 and p=20 (i.e. nranks 2200 to 8000). + +#### §P4.4.4-history Phase 4.2 batch-by-batch implementation evolution + +This subsection captures the actual implementation trajectory from +Phase 4.1 (post-AllGather-on-WORLD) to the final Phase 4.2 design +realized in Batch N. It exists to answer the question "if Strategy B +is the design, why did it take eight batches to land?" + +The short answer: **each batch is a focused, locally-testable change +that preserves the unit-test invariant**. The full design as +described above (tile-local matching + nonmortar-DOF row partition + +AllToAllv routing) involves three coupled architectural changes, +each of which on its own requires nontrivial refactoring of the +classifier and constraint-builder. Doing them all in one commit +risks a flag-day style failure where unit tests don't pass for weeks +while the design comes online. The batch sequence below trades +implementation latency for incremental correctness — every batch +ends with all unit tests green and the patch tests producing +identical numerical output to the previous batch (modulo FP +accumulation order, which surfaces as ±1 Krylov iterations at most). + +##### Batch G — Boundary subcommunicator (`m_boundary_comm`) + +**What**: Add `MPI_Comm_split` at classifier construction time, +splitting WORLD into a boundary subcomm (ranks with at least one +boundary face element) and a `MPI_COMM_NULL` placeholder for +interior ranks. + +**Why first**: Subsequent batches need the boundary subcomm to exist +before they can move collectives onto it. This batch is purely +additive — no existing collective moves yet, no behavior change. +The subcomm is constructed and stored, but the AllGather of +boundary records still runs on WORLD. + +**Risk**: Near-zero. Ranks with `m_pmesh.GetNBE() == 0` get +`MPI_COMM_NULL`; everything that follows is guarded with +`if (IsBoundaryRank())`. + +##### Batch H — Tile-partitioned face element shuffle + +**What**: Implement `TilePartition3D` (a deterministic 2D tile +grid per axis-pair derived from the bbox AllReduce), the +`ShuffledFaceElement` packed format, and `TileShuffleFaceElements` +which runs `MPI_Alltoall` + `MPI_Alltoallv` on +`m_boundary_comm` to route face elements to their tile-owning +ranks. + +**Why second**: Tile shuffling is what enables Strategy B's local +pair matching (step 6 of the protocol above). Once face elements +are on the right ranks, matching becomes a tile-local algorithm +with no MPI. + +**Test**: `test_boundary_classifier_3d` Test 8 ("tile-shuffle +routing correctness") and Test 9 ("global send/recv counts cross- +check at np=1") were added. + +**Risk**: Cross-rank vertex identity (snap-keys) was already +implemented in Phase 4.1 for the AllGather path, and Batch H +reuses that infrastructure. The risk was mostly bookkeeping +complexity in the pack format. + +##### Batch I — Local pair matching + AllGather of merged blocks + +**What**: Add `BuildLocalPairBlocks()` which runs +`MatchConformingFacePairs + AssemblePairConforming` tile-locally +on each rank's shuffled face elements. Add +`GatherPairBlocksAcrossBoundary()` which AllGather's the resulting +per-pair blocks to every rank in `m_comm` (WORLD). Also +introduces the `LocalPairBlock` nested type and the per-pair +block pack format. + +**Why third**: With face elements correctly tile-shuffled, each +rank now produces a small number of `(axis, mortar, nonmortar, +geom)` mortar blocks that are LOCAL to its tile. To preserve the +existing constraint-builder API ("every rank produces the same +SparseMatrix"), Batch I AllGather's all the blocks to every rank. +This is wasteful at scale but lets every existing test continue +to pass without changing the row-partition convention yet. + +**The §P4.8.10 bug**: A naive concatenation merge for shared +nonmortar gtdofs across tile boundaries produced wrong results. +Fixed by switching to gtdof-keyed accumulation. Discovery story +captured in the lesson. + +**Risk**: This was the highest-stakes batch. Adding tile-local +matching changes the producer; AllGather + merge changes the +consumer; the §P4.8.10 bug surfaced in the merge. After Batch I +the code was algorithmically correct end-to-end; subsequent +batches optimize the AllGather phase. + +##### Batch J — Decommission the per-rank face-element AllGather + +**What**: Remove `m_face_element_records` storage and the +`FaceElementRecord` AllGather (which had been Phase 4.1's "ship +every face element to every boundary rank" step). With face +elements now tile-shuffled in Batch H, the per-rank AllGather +became dead code. Also: rewrite `BuildFaces()` to compute +`interior_gtdofs_x/y/z` from the vertex catalog directly rather +than from the gathered face-element records. + +**Why fourth**: Pure cleanup. ~150 LOC of dead code + an +unnecessary collective on every classifier construction. With +Batch I producing the per-pair blocks tile-locally, the original +face-element AllGather has no consumer. + +**Risk**: Low. The `interior_gtdofs_*` recomputation from vertex +records was straightforward; the AllGather removal was textual. + +##### Batch K — Boundary-comm AllGather + WORLD broadcast fanout + +**What**: Refactor `GatherPairBlocksAcrossBoundary` so the +expensive AllGather of pair blocks moves from WORLD to +`m_boundary_comm`, followed by `MPI_Bcast` on WORLD to fan +the data out to interior ranks. Also fix a `[-Wunused-private-field]` +warning by removing `m_pair_match_tol_rel` from the constraint +builder (matching now lives in the classifier; the field was +vestigial). + +**Why fifth**: Batch I's `AllGatherv` on WORLD was wasteful — +interior ranks (~94% at production scale) participated in a +collective that didn't involve their data. Boundary-comm +AllGather + WORLD Bcast cuts the per-rank receive volume on +boundary ranks (they only AllGather among themselves) while +delivering the data to interior ranks via a single tree-broadcast +fanout (O(log N) latency vs O(N) bandwidth). + +**Risk**: Low. Same data, different communicator. The +broadcast root is found via `MPI_Allreduce(MIN)` of `(IsBoundaryRank() ? m_rank : INT_MAX)`. + +##### Batch L — Sparsify `FaceMortarPairBlock::A_m` + +**What**: Change `FaceMortarPairBlock::A_m`'s storage type from +`mfem::DenseMatrix` to `mfem::SparseMatrix`. Update producer +(`AssemblePairConforming`) to build sparse + Finalize. Update +consumer (`ScatterFaceBlock`) to walk via CSR `GetI/GetJ/GetData`. +Update pack/unpack and merge logic. + +**Why sixth**: This is the **dominant memory win in all of +Phase 4.2**. Lesson §P4.8.11 has the arithmetic — at N=100 the +per-block memory drops from ~800 MB dense to ~1 MB sparse. No +other change in the batch sequence comes close. + +**Why this batch and not earlier**: Earlier batches were focused +on the communication pattern; the storage type was orthogonal. +Doing the sparsification before Batch I would have entangled it +with the §P4.8.10 merge bug discovery. Doing it after the +communication structure stabilized made the sparse pack/unpack +straightforward to validate against the dense baseline. + +**Risk**: Moderate — the producer/consumer/pack/unpack/merge +quad of code paths all needed updating in lockstep, and getting +`Finalize()` placement wrong silently corrupts the CSR. +Mitigated by keeping the test suite green at every step and +validating against Batch K's output. + +##### Batch M — Per-rank C construction + +**What**: Refactor `ConstraintBuilder3D::BuildHypreParMatrix` so +it no longer allocates the full replicated SparseMatrix on every +rank. Extract `EmitConstraintTriples` as a shared helper that +both `Build()` (for tests) and `BuildHypreParMatrix` call. +`BuildHypreParMatrix` filters triples by row range on the fly +into a local-sized SparseMatrix. + +**Why seventh**: The full replicated SparseMatrix in `Build()` +was Phase 4.1's row-replication strategy — every rank held the +full C, then sliced its local rows out. At production scale +(180k rows × 16 nnz per row × 20 bytes per nnz) that's ~36 MB +per rank, replicated to every one of N ranks. Batch M brings +per-rank C-construction memory down to O(local_rows · avg_nnz) +~ 50 KB per rank. + +**The catch**: The temporary COO buffers `(rows, cols, vals)` +returned by `EmitConstraintTriples` are still O(global_nnz) per +rank — every rank still emits triples for every block in +`m_classifier.PairBlocks()`. The full asymptotic win requires +Batch N. + +**Risk**: Low. The helper extraction is mechanical; the row +filter is one branch in a single loop. + +##### Batch N — AllToAllv routing + FES-aligned row partition + +**What**: Replace `GatherPairBlocksAcrossBoundary` with +`RoutePairBlocksToRowOwners`. The new function fragments each +local pair block by FES owner of its nonmortar gtdofs, packs one +fragment per destination, and `MPI_Alltoallv`'s on `m_comm` to +route each fragment to the rank that owns its rows under the +FES TDOF partition. Also: add `GtdofOwnerRank` (binary search on +Allgather'd FES TDOF offsets), filter edge mortar rows in +`ScatterEdgeBlock` by FES ownership, remove the `n_lam_local` +argument from `BuildHypreParMatrix` (the row partition is now +data-determined), add `NumLocalRows` for callers. + +**Why last**: This is the most architecturally invasive change. +It requires every previous batch to be in place — sparse blocks +(L) make routing payloads small enough to be worthwhile; +per-rank C construction (M) is what consumes the routed +fragments correctly; the boundary subcomm + Bcast pattern (G/K) +provides the `IsBoundaryRank` API used during fragmentation. + +**The synergy with FES alignment**: AllToAllv-to-row-owner only +pays off if the row partition makes "owner" a small set per +block. With fair-split rows, a face mortar block's rows could +go to many destinations. With FES-aligned rows (rank owns row +`r` iff it owns the corresponding nonmortar gtdof in FES), a +block's rows go to a small number of destinations — typically +1, sometimes 2-4 for blocks straddling a partition boundary. +This is the §P4.8.12 lesson. + +**The HYPRE_BigInt MPI datatype gotcha**: The first cross-rank +patch test failed because the FES TDOF offset Allgather used a +hardcoded `MPI_LONG_LONG` while `HYPRE_BigInt` is `int` in +ExaConstit's HYPRE build. The fix is `HYPRE_MPI_BIG_INT`. This +is the §P4.8.13 lesson. + +**Risk**: Highest of any batch. Mitigated by: +- The np=1 invariant: at np=1 every gtdof is owned by rank 0, + so routing degenerates to a self-loop and every test produces + numerically-identical output to Batch L. +- Reusing the §P4.8.10 gtdof-keyed merge logic verbatim — only + the input source (Alltoallv recv vs AllGatherv recv) changes. +- Reusing the Batch L pack format unchanged — fragments just + have smaller `n_n` and `nnz` than Batch L blocks did. + +##### Implementation cost summary + +| Batch | LOC delta | Description | +|------:|----------:|-------------| +| G | ~150 | boundary subcomm + IsBoundaryRank guard pattern | +| H | ~600 | TilePartition3D + ShuffledFaceElement + tile shuffle | +| I | ~700 | local pair matching + AllGather + gtdof-keyed merge | +| J | -150 | decommission face-element AllGather | +| K | +80 | boundary-comm AllGather + WORLD Bcast + warning fix | +| L | +100 | sparsify A_m | +| M | +60 | per-rank C construction | +| N | +233 | Alltoallv routing + FES-aligned row partition | +| **Total** | **~1773 LOC** | full Phase 4.2 implementation | + +The line counts are net (additions minus deletions). The actual +churn is roughly 1.5× this because several batches replaced +existing functions wholesale (e.g., Batch N replaced the 425-LOC +`GatherPairBlocksAcrossBoundary` with the 483-LOC +`RoutePairBlocksToRowOwners`). + +##### Per-rank memory and communication scaling at the end + +| Aspect | Phase 4.1 (AllGather WORLD) | After Batch L (gather, sparse) | After Batch N (routed, sparse) | +|---|---:|---:|---:| +| Per-rank `m_gathered_pair_blocks` | full set, dense | full set, sparse | own slice, sparse | +| Per-rank C-construction memory | O(global_rows · avg_nnz) | same | O(local_rows · avg_nnz) | +| Per-rank temporary COO buffers | O(global_nnz) | same | O(local_nnz) | +| WORLD AllGather/AllGatherv volume | O(N · global_blocks) | same | O(global_blocks) (Alltoallv) | +| Memory at 100³ RVE per-rank, 10⁶ ranks | ~2.4 GB (dense face blocks) | ~3 MB | ~50 KB (estimate) | + +The Batch N memory drop is the asymptotic Phase 4.2 goal. Per-rank +state now scales as the rank's own piece of the periodic boundary, +which goes to zero as ranks → ∞ for fixed problem size. + +##### Why a boundary-subcomm in Phase 4.1 isn't redundant with Phase 4.2 (recap) + +Repeated for completeness — this rationale stands unchanged from +Batch G. + +It would seem that since Phase 4.2 fixes the scaling, the boundary- +subcomm in Phase 4.1 is just a stepping stone. In fact it's a +**separate, complementary improvement**: + +- Boundary subcomm: removes interior ranks from the sync. +- Distributed-hash: reduces per-boundary-rank recv volume. + +Both are needed at large scale. The boundary subcomm matters even +in Phase 4.2 because the AllReduce inside the runtime attribute +discovery (mortar §11.7.2), the consistency-check between ranks +that see overlapping attributes, and the small bcast-of-classifier- +result-to-driver all stay on the subcomm. Phase 4.2 doesn't make +those go away; it just ensures the BIG exchange (face records) is +also distributed. + +### §P4.4.5 Constraint matrix C: HypreParMatrix path + +#### Implementation status + +This section describes the **target design**, which was fully +realized in Phase 4.2 / Batch N. Earlier batches (I, K, L, M) +used a transitional "row-replicated, fair-split" partition where +every rank produced the full C matrix and sliced its local rows +out — this kept unit tests stable while the tile-shuffle and +sparsification refactors landed. Batch N converted the row +partition to FES-aligned (as described below) and replaced the +broadcast of pair blocks with `MPI_Alltoallv`-to-row-owner. +See §P4.4.4-history for the full evolution. + +#### Row partitioning + +In the Python prototype, all of C lives on rank 0. In C++, C is a +distributed `mfem::HypreParMatrix` whose rows are partitioned by +**nonmortar-DOF ownership**: world-rank `r` owns the constraint rows +whose nonmortar node lives in `r`'s TDOF range. Interior ranks own +**zero** rows but still appear in the row partition (with +`row_starts[r] == row_starts[r+1]`). This is the "empty row block +on interior rank" pattern (§P4.4.0). + +This means `n_lam_local` varies across ranks: zero on interior +ranks, positive on boundary ranks (0 ≤ n_lam_local ≤ several +hundred typically). The nonmortar-DOF ownership partition gives us +natural locality: most mortar-DOF columns referenced by row r will +also be on world-rank r or its neighbors (the nonmortar and mortar +faces of a periodic axis are typically owned by similar rank +subsets in MFEM's mesh partitioning). + +#### The communicator: WORLD, not boundary_comm + +C is constructed on **WORLD**, not on boundary_comm, even though +all the *data* in C comes from boundary ranks. The reason is +operator composition: the saddle-point solver's BlockOperator +mixes K (which lives on WORLD) and C; both must share a comm. + +This works correctly because Hypre's matvec handles ranks with +empty rows naturally — they're a no-op on the local computation +side, contribute nothing to the global send, and do receive any +inbound off-process column data that other ranks happen to need +from interior-rank-owned TDOFs (which is rare in practice since C +columns are dominantly boundary-side TDOFs). + +The CSR construction sequence: + +1. Boundary ranks build their row contributions on `boundary_comm`. +2. Boundary ranks compute their row partition on WORLD: each + boundary world-rank `r` knows its `[first_row_global, + last_row_global)`. Interior ranks are notified via a small + AllGather (one int per rank) of `n_lam_local`. +3. Each rank fills in `row_starts[2]` for its row partition; + interior ranks pass `[k, k]` (empty range starting at the + running global counter `k`). +4. HypreParMatrix gets constructed on WORLD via the standard CSR + constructor; interior ranks' `diag` and `offd` are empty + SparseMatrix shells of size `(0, n_local_cols)` and + `(0, n_offd_cols)`. + +Step 2's AllGather is small (one int per rank, so 4 bytes × nranks) +and unavoidable — every rank needs to know the global row partition +to construct the HypreParMatrix. This is unrelated to the +boundary-record exchange and stays cheap regardless of nranks. + +#### Construction pattern + +MFEM's HypreParMatrix has a "build from CSR" constructor: + +```cpp +HypreParMatrix(MPI_Comm comm, + HYPRE_BigInt global_num_rows, HYPRE_BigInt global_num_cols, + HYPRE_BigInt* row_starts, HYPRE_BigInt* col_starts, + SparseMatrix* diag, SparseMatrix* offd, HYPRE_BigInt* cmap); +``` + +where `diag` holds rows × local-cols, `offd` holds rows × off-process- +cols, and `cmap` is the offd column → global-column index map. + +For a boundary rank with non-empty rows: + +```cpp +// Step 1: gather per-rank row contributions on boundary_comm +// (already done by ConstraintBuilder3D). +std::vector local_rows = AssembleLocalRowsOnBdyComm(); + +// Step 2: AllGather of n_lam_local on WORLD to compute row_starts. +HYPRE_BigInt my_first_row, my_last_row; // computed via prefix-scan. +ComputeRowPartition(world_comm, n_lam_local, my_first_row, my_last_row); + +// Step 3: split each row into "diag" (cols owned by this world-rank) +// and "offd" (cols owned by other world-ranks). +SparseMatrix diag(n_local_rows, n_local_cols); +SparseMatrix offd(n_local_rows, n_offd_cols); +std::vector cmap; // offd col -> global col +// ... populate diag, offd, cmap ... + +// Step 4: build HypreParMatrix on WORLD. +HYPRE_BigInt row_starts[2] = {my_first_row, my_last_row}; +HYPRE_BigInt col_starts[2] = {my_first_col, my_last_col + 1}; +auto C = std::make_unique( + world_comm, n_global_rows, n_global_cols, + row_starts, col_starts, &diag, &offd, cmap.data()); +C->CopyRowStarts(); +C->CopyColStarts(); +``` + +For an interior rank with no rows: + +```cpp +// row_starts[0] == row_starts[1]: zero rows on this rank. +HYPRE_BigInt my_first_row = SomePartitionPoint; +HYPRE_BigInt row_starts[2] = {my_first_row, my_first_row}; + +// diag/offd are empty SparseMatrix shells. +SparseMatrix diag(0, n_local_cols); +SparseMatrix offd(0, 0); +std::vector cmap; // empty. + +auto C = std::make_unique( + world_comm, n_global_rows, n_global_cols, + row_starts, col_starts, &diag, &offd, cmap.data()); +C->CopyRowStarts(); +C->CopyColStarts(); +``` + +Both branches happen on every WORLD rank; the construction is a +WORLD collective. + +**Common bugs to watch for** (lessons from MFEM ex5p / ex9p): +1. Forgetting `CopyRowStarts()` / `CopyColStarts()` — leads to use- + after-free when the local arrays go out of scope. +2. Unsorted `cmap` — Hypre expects strictly increasing global + column indices in `cmap`; offd column indices must be sorted by + the corresponding `cmap[k]` value. +3. Mismatch between `diag.Size()` and `n_local_rows` — easy to slip + this when building incrementally. +4. **Mismatched row_starts on interior ranks**: every rank must + pass row_starts[r], row_starts[r+1] consistent with the global + prefix-scan. Off-by-one in the interior-rank empty-block + computation produces a HypreParMatrix that segfaults on first + matvec. Use the AllGather-of-n_lam_local + prefix-scan pattern + to guarantee consistency. + +The Python prototype's `apply_dirichlet_zero_to_C` becomes a +sparsity-preserving column zeroing. With HypreParMatrix, this means +zeroing entries in `diag` and `offd` and re-finalizing. The 24 +corner gtdofs are tiny; this is per-rank-local work with no MPI. + + + +### §P4.4.6 The element-assembly path (Phase 4.3 / Round 3) + +#### Motivation + +The HypreParMatrix path requires (a) a working Hypre+GPU build for +vector problems (currently broken), and (b) explicit CSR sparsity +management (the Step-2 hassle above). + +The EA path sidesteps both: +1. Each rank holds a `std::vector` where `MortarPair` + has the per-pair local D and A_m dense blocks plus the nonmortar/ + mortar gtdof index lists. +2. `MortarConstraintOperator::Mult(x, y)` iterates pairs: + - Gather local x slice into a small dense vector. + - Apply `D` (diagonal) and `-A_m` to populate local rows of y. +3. `MortarConstraintOperator::MultTranspose(y, x)` iterates pairs + in reverse: + - Scatter-add `D^T y_local` and `-A_m^T y_local` into x. +4. Off-rank communication: only the local rows/cols that touch + off-rank DOFs need exchange. Naturally bounded by the boundary + surface area per rank, not the full constraint count. + +This matches MFEM's `Operator` interface, integrates with `BlockOp` +identically to HypreParMatrix, and is naturally GPU-portable using +the same `mfem::forall` patterns ExaConstit already uses. + +#### Storage pattern + +```cpp +struct MortarPairLocal { + int n_nonmortar_kept; + int n_mortar_kept; + // Dense blocks (small: ~3-9 DOFs per side typically). + Vector D; // (n_nonmortar_kept,) + DenseMatrix A_m; // (n_nonmortar_kept, n_mortar_kept) + // Indices into the constraint-multiplier vector and the TDOF + // vector (vdim-expanded). + Array row_offsets_per_component; // 3 entries (vdim=3) + Array nonmortar_gtdofs_per_component; // (n_nonmortar_kept * 3,) + Array mortar_gtdofs_per_component; // (n_mortar_kept * 3,) +}; + +class MortarConstraintOperator : public mfem::Operator { +public: + virtual void Mult(const Vector& x, Vector& y) const override; + virtual void MultTranspose(const Vector& x, Vector& y) const override; +private: + // GPU-resident: copy pairs to device once at construction time. + Memory d_pairs_; + // Plus communication scaffolding for off-rank x/y entries. +}; +``` + +This is the "EA-style" approach in the same sense ExaConstit does +EA for K: per-element local matrices stored as dense blocks, applied +matrix-free without ever forming the global CSR. + +#### When is each path used? + +``` +--constraint-storage=hypre (default in Phase 4.1+4.2) +--constraint-storage=ea (Phase 4.3 onward) +``` + +CMake option `-DENABLE_EA_CONSTRAINT=ON/OFF` controls compilation. +Selectable at runtime so we can A/B test correctness on the same +binary. + +#### §P4.4.6.1 Working with BOTH `BlockBilinearForm` and `BlockNonlinearForm` + +The existing patch-test driver and saddle-point solver use +`mfem::BlockOperator` directly, populated with `Operator*` blocks. +That's the linear / `BlockBilinearForm`-equivalent path. + +ExaConstit production uses `mfem::BlockNonlinearForm` because K +is nonlinear in `u` (crystal plasticity, large deformations, +etc.). `BlockNonlinearForm` expects each block to define BOTH a +residual (`Mult(x_block, r_block)`) and a Jacobian +(`GetGradient(x_block) -> Operator&`). The constraint block C is +**linear in u** even when K is nonlinear — `C·u` is just a matrix +matvec independent of any history variable. So: + +- **Residual contribution**: `MortarConstraintOperator::Mult(u, λ_resid)` + computes `C·u`, the constraint residual. This is the lower-half + block of the saddle-point residual. +- **Jacobian contribution**: `GetGradient(u)` returns + `*this` (the operator itself, which IS the Jacobian since C is + constant in u). The Jacobian-vector products go through + `Mult` / `MultTranspose` exactly as in the linear case. + +Concretely, a `MortarConstraintBlockNonlinearFormIntegrator` +adapter (Phase 4.3 / Batch R) wraps the operator in a class that +inherits from `mfem::BlockNonlinearFormIntegrator`. The adapter +holds a reference to the `MortarConstraintOperator` and forwards +all calls. The adapter is the only piece that depends on the +`BlockNonlinearForm` interface; the operator itself is +interface-agnostic and works for both `BlockBilinearForm` +and `BlockOperator`-only use cases. + +``` + +------------------------+ + | MortarConstraintOperator| (mfem::Operator) + +-----------+------------+ + | + +-------------------------+-------------------------+ + | | + used as Operator* in BlockOperator wrapped in Block-NLF adapter + (current patch tests, saddle-point (Phase 4.3 / Batch R) + solver — Phase 4.1.A onward) (production use, + Phase 5+) +``` + +This mirrors how MFEM's own `HypreParMatrix` is used: same object, +two different interfaces, depending on whether the surrounding +form is linear or nonlinear. + +#### §P4.4.6.2 Non-conforming face mortar status (cross-reference) + +The EA path consumes the same `FaceMortarPairBlock` data as the +HypreParMatrix path. As noted in §P4.4.4-status, **non-conforming +face mortars are not implemented** in either path — the conforming +1:1 element matching is what produces the blocks. When non- +conforming face support is added in a future phase, the EA path +will pick it up automatically (a non-conforming `A_m` is just a +larger sparse matrix per pair; the operator's CSR walk doesn't +care about the geometry that produced the entries). + +#### §P4.4.6.3 Validation strategy: HypreParMatrix vs EA matvec equivalence + +**The validation contract**: for the same problem, the EA path +must produce `C·u` and `C^T·λ` results that are identical to +the HypreParMatrix path's matvecs to floating-point precision. +"Floating-point precision" means equal up to FP order-of-summation +tolerance, typically ~1e-13 for double-precision. + +**Why FP-precision and not bit-exact**: the two paths sum +contributions in different orders. The HypreParMatrix path sorts +CSR rows by column and does a structured sum during matvec. The +EA path walks pairs in pair-list order. Same operations, different +summation order — bit-exactness is not achievable in general. + +**The validation harness — split across Batches Q and S**: + +The validation lives in two places, each catching a different +class of bug: + +*Batch Q — matvec-level A/B harness in `test_mortar_constraint_operator`* + +1. Build the same problem two ways: (a) `BuildHypreParMatrix()` + → `mfem::HypreParMatrix*`, (b) `MortarConstraintOperator(cl)`. +2. Check dimensions match: `H->Height() == op.Height()`, + `H->Width() == op.Width()`. (Already exercised in Batch O test 2.) +3. Apply both paths to the same random `u` and compare: + `H * u_random == op * u_random` to tolerance + `1e-12 * (||C||_F * ||u||_2)`. At multiple mesh sizes (2³, + 4³, 6³, 8³) to catch size-dependent bugs. +4. Apply both paths to the same random `λ`: + `H^T * λ_random == op^T * λ_random` (with `mfem::TransposeOperator` + wrapping H and `MultTranspose` on op). +5. Zero-input invariant: `Mult(0, _) = 0` and `MultTranspose(0, _) = 0`. +6. Negative test (harness self-check): perturb the EA output by + 1e-3 and verify the comparison flags it. Guards against the + tolerance being too loose to catch real bugs. + +This batch runs at np=1, matching the rest of the unit-test suite. +The Alltoallv import/export topology IS built at construction time +even at np=1 (it just ends up empty), so construction-time bugs +are caught here. What is NOT caught here: bugs in the actual +data exchange between ranks, since at np=1 no exchange occurs. + +*Batch S — end-to-end + cross-rank validation* + +1. Wire `--constraint-storage=ea` into the patch-test driver. +2. Add an A/B mode that constructs both paths in one run and + reports any divergence in the resulting `du` field. +3. Run the existing patch tests at np=4, np=7 with the EA path + and verify identical displacements (within Krylov tolerance) + to the HypreParMatrix path. This is where the cross-rank + Alltoallv logic gets exercised end-to-end. +4. Add a saddle-point solver overload accepting + `const mfem::Operator&` instead of `const mfem::HypreParMatrix&` + so the EA operator slots into the existing solver without + duplicating the Krylov setup code. + +**Why the split**: the matvec-level Batch Q is fast and runs +in CI at np=1, so any algorithmic regression in `Mult` / +`MultTranspose` or in the per-pair scatter is caught immediately. +The end-to-end Batch S exercises the Alltoallv exchange paths +that np=1 can't reach, but at the cost of running at np>1 (which +the unit-test harness doesn't support). Both layers are needed +to fully validate the EA path. + +**Why this validation matters for ExaConstit production**: the +EA path is what ExaConstit will actually run (matrix-free, GPU- +friendly). If it disagrees with the HypreParMatrix path on a +small problem, it'll disagree silently at production scale where +no reference is available. The A/B harness on the small patch +tests is the only place we can hold them to bit-tight tolerance. + +#### §P4.4.6.4 Phase 4.3 batch sequence + +Same incremental phasing principle as Phase 4.2 (§P4.4.4-history ++ §P4.8.14): each batch lands a focused, locally-testable change +with the test suite green at every step. + +| Batch | What | Why this batch | Status | +|------:|------|----------------|:------:| +| O | Design + skeleton: `MortarConstraintOperator` header, stub `.cpp` (Mult/MultTranspose abort with clear message), construction-only test (`test_mortar_constraint_operator`), CMake registration, doc updates. | Establish the type, size, and lifecycle so subsequent batches can implement against a stable interface. The MFEM_ABORT in the stubs prevents silent zero-output bugs from masking missing-implementation issues. | done | +| P | Implement `Mult` and `MultTranspose` on CPU. Build the off-rank import / export topology in the constructor. Per-pair scatter loop. Single-rank tests pass. | The core algorithmic work. CPU-first lets us validate the pair-loop semantics before adding GPU complications. | done | +| Q | A/B validation harness at multiple mesh sizes, zero-input invariant, harness self-check (negative test). Tightened tolerance to `1e-12` per §P4.4.6.3 contract. | The firewall: any future change to the EA path that breaks consistency with HypreParMatrix path gets caught here. The cross-rank np>1 path is exercised end-to-end in Batch S; this batch is the matvec-level contract at np=1. | done | +| R | `MortarSaddlePointSystem` adapter that composes user-provided K-residual / K-Jacobian closures with the EA constraint operator into a single `mfem::Operator` exposing combined `Mult` (saddle-point residual) and `GetGradient` (saddle-point Jacobian as a `BlockOperator`). Plus `MortarConstraintOperator::ComputeInvDiagSchur` — the EA-path equivalent of `BuildInvDiagSchur(HypreParMatrix C, ...)` for block-Jacobi preconditioning, computed directly from per-pair blocks (Option 2, no matvec probes). | Prerequisite for Phase 5 (ExaConstit integration). The closure-based interface fits BOTH the linear `BlockBilinearForm`-equivalent case (closure returns the same `K_op` every call) and the nonlinear `BlockNonlinearForm` case (closure delegates to `ParNonlinearForm::GetGradient`). The Schur-diag method makes the EA preconditioner construction clean for Batch S. | done | +| S | Wire the EA path into the patch-test driver behind `--constraint-storage=ea` and `--ab-compare` CLI flags (the latter runs both paths in one process and asserts displacement agreement). Add a saddle-point solver overload `Solve(K, MortarConstraintOperator, ...)` that uses `ComputeInvDiagSchur` for the Schur-diag preconditioner block. Refactor the existing `Solve` body into a shared `SolveImplInternal` helper to avoid duplicating ~125 LOC of Krylov plumbing. Add a dedicated `test_patch_3d_pbc_ea_compare` driver that runs all three patterns (homogeneous / strip / checkerboard) under `ab_compare = true`, registered at np=1 by convention but designed to be re-run at np>1 for cross-rank Alltoallv exercise. | End-to-end validation in the production driver, not just unit tests. This is the cross-rank firewall: bugs in the EA path's off-rank import / export topology that np=1 unit tests cannot reach (because the Alltoallv buffers are empty at np=1) get caught here when the test is re-run at np=4 or np=7 with `||du_ea - du_hp||_inf` above tolerance. | done | +| X (Phase 4.3.B) | GPU port via `mfem::forall`. First pass: pre-flatten per-pair-block data into `mfem::Vector` / `mfem::Array` at construction time (`BuildFlatRowArrays`), rewrite forward `Mult` as a single forall over `m_n_active_rows` with `Read`/`Write` memory-manager annotations. `MultTranspose` and `ComputeInvDiagSchur` stay host-only with `HostRead`/`HostReadWrite` annotations (DEVICE_DEBUG-clean without atomic-add complexity). MPI Alltoallv stays host-only by design. | First step toward GPU portability. The forward direction is the hottest path; transpose and preconditioner setup are amortized cost. | first pass done; atomic-add scatter for `MultTranspose` is a follow-up | + +#### §P4.4.6.5 Per-pair pseudocode (algorithmic reference) + +For one face-mortar block with `n_n` local nonmortar rows and +`n_m` mortar columns, with `A_m` stored as a sparse CSR: + +**Mult (`y = C·x`)** — emitted into local row range +`[row_off, row_off + 3*n_n)`: + +``` +for each component c in {x, y, z}: + for k in 0..n_n: + u_c_k = x[g_n[k] for c] + y_local = D[k] * u_c_k // diagonal contribution + for each (l, A_kl) in A_m row k: + u_c_l = x[g_m[l] for c] // possibly off-rank + // (use import buffer) + y_local -= A_kl * u_c_l + y[row_off + 3*k + c] = y_local // overwrite, not accum + // (block 0 — start of + // matvec) + // For subsequent blocks + // emitting same row + // range, +=, but in our + // FES-aligned partition + // each row appears in + // exactly one block. +row_off += 3 * n_n +``` + +**MultTranspose (`y += C^T·x`)** — reads x in local row range +`[row_off, row_off + 3*n_n)`: + +``` +for each component c in {x, y, z}: + for k in 0..n_n: + x_k = x[row_off + 3*k + c] + y[g_n[k] for c] += D[k] * x_k // local TDOF (always + // owned by this rank by + // FES-aligned partition) + for each (l, A_kl) in A_m row k: + // y[g_m[l] for c] -= A_kl * x_k + // — but g_m[l] may be off-rank. + if g_m[l] is FES-owned by this rank: + y[g_m[l] for c] -= A_kl * x_k + else: + export[off_rank_slot, c] -= A_kl * x_k + // export buffer is flushed via Alltoallv at + // end of MultTranspose; receivers ADD into y. +row_off += 3 * n_n +``` + +For edge-mortar blocks, the same pseudocode applies with the +addition of a row-owner filter at the top: + +``` +if classifier.GtdofOwnerRank(nonmortar_g_xyz[0]) != my_rank: + row_off += 3 * n_n // skip this rank's contribution + // (still increment row_off so other + // ranks' blocks land in the right + // global rows after the rank-major + // prefix-sum) + continue +``` + +This pseudocode is the implementation contract for Phase 4.3 / +Batch P. + +#### §P4.4.6.6 `MortarSaddlePointSystem` design rationale (Batch R) + +The Batch R adapter turns "an EA constraint operator + a user's +K residual / Jacobian" into a single `mfem::Operator` that +presents the saddle-point system + +\f[ + \begin{bmatrix} K(u) & C^T \\ C & 0 \end{bmatrix} + \begin{bmatrix} u \\ \lambda \end{bmatrix} +\f] + +with `Mult` returning the residual and `GetGradient(x)` returning +the assembled `BlockOperator`. Three design choices warrant +explanation. + +**Composition, not inheritance.** Initial sketches had the +adapter inherit from `mfem::BlockNonlinearForm`. That doesn't +fit: `BlockNonlinearForm` builds its block structure from per- +element `BlockNonlinearFormIntegrator::AssembleElementGrad` +contributions, but our constraint matrix C is **globally +coupled** (it links nonmortar gtdofs to mortar gtdofs that may +be on entirely different elements and ranks). The per-element +assembly model doesn't fit. So instead, `MortarSaddlePointSystem` +COMPOSES — it holds a const reference to a +`MortarConstraintOperator` and accepts the K side via +`std::function` callbacks. This sidesteps MFEM's block-form +internals entirely and works above whatever K mechanism the +user has set up. + +**Callback-based K abstraction.** The adapter accepts: +- `KResidualFn = std::function` +- `KJacobianFn = std::function` + +This single interface fits both the linear and nonlinear cases: +- **Linear K** (current patch tests, `BlockBilinearForm`-equivalent): + the closure returns the same `&K` every time. The adapter + rebuilds its `BlockOperator` per `GetGradient` call but the + underlying K Jacobian doesn't change. +- **Nonlinear K** (production, `BlockNonlinearForm`): + the closure delegates to `ParNonlinearForm::GetGradient(u)`, + which internally re-linearizes K at the current Newton iterate. + The adapter forwards the result into the saddle-point block + layout. + +The closure-based interface keeps the adapter's API stable +across the linear-vs-nonlinear axis, so Phase 5 (ExaConstit +integration) doesn't need to introduce a different adapter for +production. + +**Schur-diagonal computed from blocks, not matvec probes.** The +`BuildInvDiagSchur(HypreParMatrix C, inv_diag_K)` formula in +`saddle_point_solver.cpp` walks the HypreParMatrix CSR. The +EA path needs the same quantity but doesn't have a CSR. Two +options were considered: + +1. **Probe with unit vectors.** Compute column `j` of `C` via + `C * e_j` (one matvec per column), then build the diagonal of + `C diag(K)^{-1} C^T` from those probes. **Cost**: `Width()` + matvecs to build the preconditioner. Setup-time only, but at + production scale (`Width() ~ 1e8`), each Krylov iteration is + typically far less work than that — would dominate setup. + +2. **Compute directly from per-pair blocks** (chosen). The Schur + diagonal entry at row `(block, k, c)` decomposes as + `D_k^2 \cdot \mathrm{Dinv}[g_n^c] + \sum_l A_{kl}^2 \cdot \mathrm{Dinv}[g_m^c]` + — a single walk through the same per-pair data the operator + already holds. Mirrors `BuildInvDiagSchur`'s formula exactly, + just walking pair blocks instead of CSR. Costs one Allgatherv + on `inv_diag_K` (matching the HypreParMatrix path's pattern) + plus a local pair-block walk. Setup cost is `O(local_rows)`, + not `O(Width)`. + +Option 2 was the right call because: +- It produces bit-equivalent results to option 1 (modulo summation + order — same FP-rearrangement tolerance as Mult vs HypreParMatrix + matvec). +- Setup cost stays bounded by problem size, not by `Width()`. +- The implementation is short (~80 LOC of pair-walk code that + shares structure with `Mult`). + +The result lives on `MortarConstraintOperator::ComputeInvDiagSchur` +to keep the EA path self-contained — Batch S consumes it via the +saddle-point solver overload taking `const mfem::Operator&`. + +**Lifetime contract.** `GetGradient(x)` returns a reference to an +internal `BlockOperator` whose lifetime extends until the next +`GetGradient` call. The user's Jacobian pointer (returned by their +`KJacobianFn`) must remain valid for at least the same window. This +matches `mfem::ParNonlinearForm` semantics — its internal Jacobian +storage is reused across iterations. + +#### §P4.4.6.7 Saddle-point solver overload + A/B patch driver (Batch S) + +Batch S is the production-integration step: the patch-test driver +gains a runtime choice of constraint storage (HypreParMatrix vs EA) +and an A/B-compare mode that runs both paths and asserts +displacement-field agreement. Three design decisions are worth +explaining. + +**Refactor `Solve` rather than duplicating it.** The HypreParMatrix +overload's body is ~125 LOC: dimension checks, BlockOperator +construction, BlockDiagonalPreconditioner setup, Krylov configuration, +solve, solution extraction. The EA overload differs only in how it +computes `inv_diag_S` (`ComputeInvDiagSchur` vs `BuildInvDiagSchur`) +and what types it casts to feed into `BlockOperator::SetBlock`. Two +cleaner options were considered: + +1. **Duplicate the body.** Two `Solve` overloads, each ~125 LOC. Same + logic in both, two places to fix any bug. Rejected — the + maintenance cost of doubled Krylov plumbing dominates the + one-time cost of refactoring. + +2. **Extract a shared `SolveImplInternal`.** Each overload computes + its own `inv_diag_S` via its own path, then delegates to the + shared helper which takes K and C as `mfem::Operator&` (the + common base class). All BlockOperator setup, RHS assembly, + Krylov solver instantiation, and solution extraction lives in + one place. + +Option 2 is what landed. The pattern generalizes to any future +overload that varies only at the preconditioner-construction step +(e.g., a future direct-solver overload). + +**Keep K as `HypreParMatrix`, vary only C.** The Batch S overload +is `Solve(const HypreParMatrix& K, const MortarConstraintOperator& C_op, ...)` +— K stays as `HypreParMatrix` because that is what the current +patch-test driver assembles. Switching K to a matrix-free +representation is a separate concern: it requires either a real +nonlinear K from `ParNonlinearForm` (Phase 5) or the `BlockBilinearForm`- +equivalent linear-K-via-Operator path. Either way, that change +expands the saddle-point solver's scope significantly and benefits +from its own focused batch. + +The forward-decl-only header convention applies here: +`saddle_point_solver.hpp` forward-declares +`MortarConstraintOperator` rather than including its header, +keeping include-graph weight low. The full include lives in the +`.cpp`. + +**A/B compare lives at the driver layer, not the solver layer.** +The cleanest place to compare HypreParMatrix vs EA paths is the +patch-test driver, not the saddle-point solver. The solver only +sees one C at a time; the driver builds both, runs the solver +twice, and computes `||du_ea - du_hp||_inf`. This pattern keeps the +solver simple — there is no "which path do I take?" branch inside +`Solve` — and makes the comparison metric (final-displacement +agreement) match what production cares about. A solver-internal +A/B mode would have had to compare per-iteration residuals or +per-matvec results, which are FP-rearrangement-noisy and harder to +reason about. + +The driver's A/B logic is: +1. If `ab_compare = false`, run only the path selected by + `cfg.constraint_storage`. (Default behavior — preserves all + pre-Batch-S patch-test runs unchanged.) +2. If `ab_compare = true`, build both `C` and `C_op`, call the + appropriate `Solve` overload twice (once with each), compute + `||du_ea - du_hp||_inf` with global `MPI_MAX` reduction, and + fail the test if the difference exceeds `cfg.ab_compare_tol`. +3. The "primary" path's results (chosen via `cfg.constraint_storage`) + flow into steps 10–12 (recovery, ⟨F⟩, constraint residual). + This means `--constraint-storage=ea --ab-compare` is the + "validate EA path against HypreParMatrix reference" mode, while + `--constraint-storage=hypre --ab-compare` is the dual. + +**Cross-rank validation strategy.** The new +`test_patch_3d_pbc_ea_compare` test driver is registered at np=1 in +CMake, but is intended to be re-run manually at np=4 / np=7 by the +developer (matching the convention for the other patch tests). +Specifically: +- At np=1, `MortarConstraintOperator::Mult` and `MultTranspose` + hit the same algorithmic path as np>1 — the off-rank import / + export topology IS built at construction, but the Alltoallv + buffers happen to be empty because no gtdofs are off-rank. So + np=1 catches algorithmic bugs in `Mult` / per-pair scatter. +- At np>1, the Alltoallv calls actually exchange data. A bug in + the topology construction (e.g. wrong destination rank in the + `gtdof_to_slot` lookup, or a sign error in the export staging) + shows up as `||du_ea - du_hp||_inf` orders of magnitude above + tolerance. + +This np-progression pattern — np=1 in CI, np>1 manual — is the +same as for the existing patch tests. The cost is that np>1 +regressions can land without immediately failing CI; the benefit +is that the unit test suite stays fast. + +**Tolerance choice for `ab_compare_tol`.** The two paths' Krylov +solves diverge in FP-summation order (each path's matvec sums in +a different order). The compounding effect across iterations can +move the final residual by more than the per-iteration FP- +rearrangement bound predicts. Empirical observation on the 4³ +patch tests at np=1 is `~1e-9`; the default `ab_compare_tol = 1e-7` +leaves 2 orders of magnitude of headroom, sufficient for cross- +rank summation order variance at np up to several dozen. + +If `ab_compare_tol` ever needs to be tightened (e.g., for a more +discriminating cross-rank validation), the matvec-level firewall +in Batch Q can be re-tightened at the same time. The two +tolerances are coupled — Batch S tolerance must always be looser +than Batch Q tolerance because Krylov compounding amplifies +matvec rearrangement. + +#### §P4.4.6.8 GPU port via `mfem::forall` (Batch X / Phase 4.3.B) + +Phase 4.3.B is the GPU port. The CPU EA path is correct and +validated via Batches Q–S; the goal here is to make it run on +GPU through `mfem::forall` with proper memory-manager +annotations. This subsection documents the design choices for +the first pass. + +**Pre-flatten data at construction time.** The CPU implementation +walks per-pair-block C++ structs (`m_local_edge_pairs`, +`classifier.PairBlocks()`) using `std::map` lookups +(`m_gtdof_lookup`, `m_import_gtdof_to_slot`). Neither maps nor +arbitrary structs are GPU-friendly. The `BuildFlatRowArrays()` +helper (called once at the end of the constructor) walks every +pair block ONCE and produces flat `mfem::Vector` / +`mfem::Array` arrays: + + * `m_row_D[i]` — diagonal `D_kk` value for row `i`. + * `m_row_g_n_local[i*kVDim + c]` — local FES TDOF index for the + nonmortar component `c` of row `i`. -1 = sentinel. + * `m_row_csr_off[i]` — prefix-sum start of row `i`'s CSR slice. + * `m_csr_A[k]` — A_kl value for CSR entry `k`. + * `m_csr_g_m_local[k*kVDim + c]` / `m_csr_g_m_recv[k*kVDim + c]` — + paired tagged-index encoding for the mortar component. The + convention is "exactly one of these is ≥ 0 (the other is -1) + if the component is real, or both are -1 for sentinel". This + avoids std::map at matvec time at the cost of two int reads + per CSR entry per component. + +The flat-arrays form increases construction-time memory by +roughly `O(n_active_rows + total_csr_entries)` ints + doubles — +small relative to the per-pair-block storage we already keep, and +amortised across all Krylov iterations of a Newton step. + +**Per-pair scatter becomes a single `mfem::forall` over rows.** +The forward `Mult`'s old triple-nested loop (per pair, per `k`, +per `c`, per CSR entry) flattens to: + +``` +mfem::forall(m_n_active_rows, [=] MFEM_HOST_DEVICE (int i) { + for (int c = 0; c < kVDim; ++c) { + int gn = m_row_g_n_local[i*3+c]; + if (gn < 0) continue; // sentinel + double y_c = m_row_D[i] * x[gn]; + for (int e = csr_off[i]; e < csr_off[i+1]; ++e) { + int gm_loc = m_csr_g_m_local[e*3+c]; + int gm_recv = m_csr_g_m_recv[e*3+c]; + double u_m; + if (gm_loc >= 0) u_m = x[gm_loc]; + else if (gm_recv >= 0) u_m = recv_buf[gm_recv]; + else continue; // sentinel + y_c -= csr_A[e] * u_m; + } + y[lambda_off + c] = y_c; + } +}); +``` + +Each thread handles one row's `kVDim` outputs, with no shared +state and no atomic writes — every `y[lambda_off + c]` is unique +across threads. This is the embarrassingly-parallel form GPU +forall machinery is designed for. + +**MPI Alltoallv stays on host.** Standard MPI implementations +treat host pointers; GPU-aware MPI exists but adds significant +build complexity. Our pattern: + + 1. **Send-pack** (host): `x.HostRead()` → fill `send_buf` → + MPI_Alltoallv → recv into `recv_buf.HostWrite()`. + 2. **Matvec** (device): `recv_buf.Read()` returns a device + pointer (memory manager migrates host → device on first + read after a host write). + 3. **Result** (device): `y.Write()` returns a device pointer; + the kernel writes there directly. + +The memory manager handles migrations transparently. Under +`DEVICE_DEBUG`, any attempt to read host-stale or device-stale +data triggers a clear assertion failure rather than corrupting +silently. + +**`MultTranspose` stays host-only for first pass.** The transpose +has many-to-one scatter — multiple rows can write to the same +y entry (a mortar gtdof FES-local on this rank can be referenced +from many pair blocks; off-rank export staging is also a many- +to-one accumulation). A correct GPU implementation needs atomic +adds on every scatter target, which works but is materially more +involved than the forward direction. For the first pass we keep +`MultTranspose` as a single sequential walk over the same flat +arrays on the host with `HostRead`/`HostReadWrite` annotations. +This is DEVICE_DEBUG-clean and validates the flat-array +infrastructure; an atomic-add scatter rewrite is a follow-up +batch. + +**`ComputeInvDiagSchur` stays host-only.** Setup-time only (called +once per Newton step from the saddle-point solver during +preconditioner construction, before any Krylov iterations run). +Not in the matvec hot path. Refactoring it to flat arrays would +provide little benefit since its cost is amortised across +hundreds-to-thousands of Krylov iterations. The body uses +`HostRead` on `inv_diag_K_local` and `HostWrite` on `schur_diag` +to be DEVICE_DEBUG-clean. + +**`MortarSaddlePointSystem::Mult` annotations.** The block-vector +view construction uses `HostReadWrite` on the input block and +`HostWrite` on the output block to register the access intent +with the memory manager. The K-residual callback and the +mortar operator's own `Mult` / `MultTranspose` then call their +own `Read` / `Write` on the sub-vector views, which dispatches +correctly because the sub-vectors alias the same memory region. + +**Tolerance under `DEVICE_DEBUG`.** The Batch Q matvec A/B +tolerance (1e-12) and the Batch S patch-test A/B tolerance (1e-7) +should hold unchanged on host. On device, FP-rearrangement may +shift these by up to one order of magnitude due to different +summation orders in the per-row inner loop (the new flat-array +form sums in CSR-entry order rather than the per-pair-block +order the original code used). If A/B tests start failing at +1e-12 after the GPU port, the right move is to bump Batch Q's +tolerance to 1e-11 — that captures the FP-rearrangement shift +without masking real bugs. + +#### §P4.4.6.9 Phase 4.3.B current state and next steps + +This subsection is the entry point for someone returning to the +GPU port work cold. It captures (a) what's actually been +implemented and validated, (b) what's specifically pending, and +(c) the recommended order of operations for finishing. + +##### What's implemented and validated + +**Sandbox-validated** (host-only syntax + `-Wall -Wextra` + +algorithm correctness via Python regression and the existing +unit / patch tests): + + * `MortarConstraintOperator::BuildFlatRowArrays()` — two-pass + walk that pre-flattens the per-pair-block data into + `mfem::Vector` / `mfem::Array` arrays at construction + time. Walks the same iteration order as `Mult` / + `MultTranspose` / `ComputeInvDiagSchur` / + `EmitConstraintTriples` (edges first with row-owner filter, + then face mortars in `FacePairs()` order with quad-then-tri). + Produces: + - `m_row_lambda_off[i]` — first lambda index for row `i`. + - `m_row_D[i]` — diagonal `D_kk` value for row `i`. + - `m_row_g_n_local[i*3+c]` — local FES TDOF index for + nonmortar component `c` (-1 for sentinel). + - `m_row_csr_off[i]` — prefix-sum start of row `i`'s CSR + slice. + - `m_csr_A[k]` — A_kl value for CSR entry `k`. + - `m_csr_g_m_local[k*3+c]` / `m_csr_g_m_recv[k*3+c]` — + paired tagged-index encoding for off-rank vs. local + lookups (exactly one is ≥ 0 if real, both -1 for + sentinel). + + * `MortarConstraintOperator::Mult` — forward direction + rewritten as `mfem::forall(m_n_active_rows, kernel)`. Host + side does the send-pack and `MPI_Alltoallv` (with + `HostRead`/`HostWrite` annotations); device kernel reads the + flat arrays via `Read()` and writes `y` via `Write()`. No + `std::map` lookups, no struct walks, no host-only API calls + in the kernel. + + * `MortarConstraintOperator::MultTranspose` — first-pass + rewrite that uses the flat arrays but stays as a single + sequential host walk. `HostRead`/`HostReadWrite` annotations + throughout. Sequential because the transpose has many-to-one + scatter and atomic-add scatter is the planned follow-up + (see "Next steps" below). + + * `MortarConstraintOperator::ComputeInvDiagSchur` — host-only + by design (setup time, not hot path). All Vector accesses use + typed `HostRead`/`HostWrite` accessors with raw pointers + hoisted above per-element loops. + + * `MortarSaddlePointSystem::Mult` — block-vector views + constructed via `HostReadWrite` on input and `HostWrite` on + output. Sub-vector views alias the parent buffers, so + callbacks' own `Read`/`Write` calls dispatch correctly. + + * `SaddlePointSolver::SolveImplInternal`, `BuildInvDiagK`, + `BuildInvDiagSchur`, `DiagonalScaler::Mult` — all per-element + Vector accesses converted to raw `HostRead`/`HostWrite` + pointer pattern. + + * Patch driver (`patch_test_driver_3d.cpp`) — A/B compare diff + loop, `u_total` recovery loop, constraint-residual loop, and + `ComputeVolumeAveragedF` u-copy loop all converted to raw + pointers. + +**Validated on real MFEM (Mac, host-only build)**: + + * All existing unit tests pass under normal build. + * `test_patch_3d_pbc_ea_compare` passes at np=1 (and remains + available for np>1 cross-rank Alltoallv exercise). + * **Patch tests run cleanly under `DEVICE_DEBUG`** — the user + confirmed this after the §P4.8.17 fixes landed. This is the + significant validation gate: every Vector access in the + saddle-point solver, constraint operator, and patch driver + has its memory-manager intent declared correctly. + +**Stub extensions** (in `/tmp/mfem_stub/mfem.hpp`): + + * `mfem::Vector` and `mfem::Array`: `Read`/`Write`/`ReadWrite`/ + `HostRead`/`HostWrite`/`HostReadWrite` returning raw pointers + (in real MFEM they go through the memory manager). + * `mfem::forall(N, body)` template that runs serially on host + for syntax-checking. + * `MFEM_FORALL(i, N, body)` macro form. + * `MFEM_HOST_DEVICE` no-op define. + +##### What's pending + +In rough order of difficulty / dependency: + +1. **Atomic-add scatter for `MultTranspose`** (medium effort). + The flat-array form is already in place; the conversion + replaces the sequential host loop with `mfem::forall(...)` + that does atomic adds into both `y` (for FES-local writes) + and the export staging buffer (for off-rank writes). The + stub will need an `mfem::AtomicAdd` (or equivalent) added. + In real MFEM, `MFEM_HOST_DEVICE` atomic operations are + exposed via the `mfem::AtomicAdd` template. The kernel + structure stays the same as the current sequential walk — + each thread handles one row, walks its CSR slice, and atomic- + adds into output positions. + + **Why this is non-trivial**: the export staging buffer is a + `std::vector` currently — it needs to become an + `mfem::Vector` so atomic adds through the memory manager are + well-defined. Then the AOS layout (`slot * kVDim + c`) stays + the same; only the access path changes. + + **Validation strategy**: the existing + `test_mortar_constraint_operator`'s A/B test (Batch Q) at + np=1 will catch any regression in `MultTranspose` correctness + immediately, and the cross-rank A/B test at np=4 / np=7 will + catch any cross-rank correctness issue. Tolerance may need + to bump from 1e-12 to 1e-11 because atomic-add summation + order is non-deterministic across threads (each run can + produce slightly different results within FP-rearrangement + bounds). + +2. **Real device build validation** (low-to-medium effort, + high-value). + Sandbox + `DEVICE_DEBUG` validates memory-manager hygiene; + only a real CUDA or HIP build exercises the kernels on + hardware. The plan: + + a. Build MFEM with `MFEM_USE_CUDA=YES` (or `MFEM_USE_HIP=YES` + for AMD targets). + b. Build the patch tests against that MFEM. + c. Run with `--device cuda` (or `hip`) flag added to the + device-init sequence at the top of `main`. + d. Compare output displacements against the host-only build + — should agree within `1e-11` (`1e-12` was the host A/B + tolerance; one extra order of magnitude of slack covers + FP-rearrangement on device). + + **Most likely failure mode**: a CSR-entry-component encoding + mismatch where `m_csr_g_m_recv` is computed incorrectly. + This would manifest as off-rank pairs producing wrong + contributions only at np > 1 — the np=1 case never exercises + off-rank paths. The Batch Q A/B test (cross-rank, n=8 mesh) + is the diagnostic to lean on. + +3. **Performance work** (open-ended, lower priority). + Once correctness on device is confirmed, profile and + optimize. Likely candidates: + - Coalescing on the flat arrays (the current AOS layout for + `m_csr_g_m_local` / `m_csr_g_m_recv` is `[k*3 + c]` — + grouping by component instead might give better warp- + level coalescing on CUDA). + - Register pressure in the kernel body (the inner loop + reads 4 ints + 1 double + 1 double per CSR entry; if + this exceeds register budget it spills to local memory). + - Possibly per-pair shared-memory tiling for very-dense + face-mortar blocks, though for the patch tests the per- + row CSR slices are short (~10-20 entries) so this + probably isn't worth the complexity. + + The existing Caliper instrumentation (`CALI_CXX_MARK_SCOPE`) + in `Mult` / `MultTranspose` / `ComputeInvDiagSchur` will show + where the time actually goes once a real device build is + available. Don't optimize blind. + +4. **Convert `block.A_m.GetData()` SparseMatrix accesses to + `GetMemoryData().HostRead()` form** (very low effort, defensive + only). + These are `SparseMatrix` accesses (not Vector), and SparseMatrix + data is host-resident throughout the program lifetime by + construction. They don't currently fail under `DEVICE_DEBUG`. + Switching to the typed-accessor form would future-proof against + any case where a SparseMatrix gets device-touched (e.g., if a + future `BuildFlatRowArrays` extension does its walk on device). + Not urgent. + +##### Recommended order when circling back + +1. **Verify the host-only Mac build is still green**. Re-run all + patch tests + `test_patch_3d_pbc_ea_compare` with `--f-sweep` + at np=4 and np=7 to confirm nothing has bit-rotted. +2. **Set up a real CUDA or HIP build of MFEM** in the + exaconstit_hip_build tree. ExaConstit has experience with + this; reuse the existing build infrastructure. +3. **Run the sandbox-validated code on device**, host-only + first (forward `Mult` only), to validate the `mfem::forall` + path actually compiles and runs. The `MultTranspose` and + `ComputeInvDiagSchur` paths are explicitly host-only and will + naturally fall through to host execution. +4. **Tackle atomic-add `MultTranspose`** — the natural next + batch after device-build validation. Pattern is established + by the forward `Mult`; only the scatter side changes. +5. **Performance work** — only after correctness is end-to-end + green on device. + +##### Key invariants to preserve + +These are non-negotiable across any future GPU work: + + * **`BuildFlatRowArrays` walk order MUST match `Mult` / + `MultTranspose` / `ComputeInvDiagSchur` / `EmitConstraintTriples`.** + Edges first (with row-owner filter), then face mortars in + `FacePairs()` order with quad-then-tri. Any divergence breaks + row-index alignment with `Height()`. + + * **Sentinel handling**: `m_row_g_n_local[i*3+c] = -1` and + `m_csr_g_m_local[k*3+c] = m_csr_g_m_recv[k*3+c] = -1` both + mean "skip this contribution silently." The kernel must + NOT increment row offset or write to `y` for a sentinel + component — match what the original ScatterEdgeBlock did. + + * **Batch N's row-owner invariant**: nonmortar gtdofs are + always FES-local for owned rows. Encoded into + `m_row_g_n_local[]` always being a local FES TDOF index + (or -1 sentinel), never an off-rank index. If this + invariant is violated, either the row-owner filter or + the routing logic has a bug — not the GPU port. + + * **Batch L's mortar gtdof convention**: face-mortar pair + blocks store mortar gtdofs as x-component only; + `m_gtdof_lookup` maps x → (x, y, z). The `BuildFlatRowArrays` + walk uses this lookup to per-component encode into + `m_csr_g_m_local` / `m_csr_g_m_recv`. If a future change + extends pair blocks to per-component gtdofs directly, the + encoding step in `BuildFlatRowArrays` simplifies but the + resulting flat-array form must be unchanged. + + * **DEVICE_DEBUG-clean access pattern**: every Vector access + in any new code MUST use `HostRead`/`HostWrite`/`HostReadWrite` + (or device counterparts), not `GetData()`/`operator()`/ + `operator[]`. See §P4.8.17 for the rule. + +##### Cross-references + + * §P4.4.6.8 — design rationale for the GPU port (why this + architecture, why the choices). + * §P4.8.16 — lesson on pre-flattening host-side data before + chasing `mfem::forall`. + * §P4.8.17 — lesson on `Vector::GetData()` / + `Vector::operator()` being DEVICE_DEBUG traps. + * §P4.13 done-criteria — Phase 4.3.B item. + +#### §P4.4.6.10 Phase 4.4 — Non-conforming face mortar + +This subsection is the architectural plan for completing Phase +3.5 / Phase 4.4 (the architecture doc names the algorithmic phase +3.5, but the C++ port version of it is Phase 4.4). The plan was +built by carefully re-reading the master architecture doc, the +2D non-conforming code (which is the proven design template), +and the existing C++ face-mortar assembler code, then refining +with current literature only where the existing design genuinely +needs an answer. + +##### What this phase does and does not change + +**Scope (what's in):** Add support for opposite periodic faces +that have non-matching node positions on the same flat +axis-aligned interface — e.g., the `x = 0` face is subdivided +into a 4×4 grid of quads while the `x = L` face is subdivided +into a 5×5 grid. Element types remain pure: all-hex (so all +face elements are quads) or all-tet (all face elements are +tris). Faces remain flat and axis-aligned. Full periodicity +(all 3 axis pairs) only. + +**Scope (what's out):** + * Mixed quad-tri pairings (a quad face on one side paired with + a tri face on the other). The architecture-doc §3.7 algorithm + handles this case but it doubles the testing surface. + Defer until pure-element non-conforming is solid. + * Curved or non-planar faces. The 2D-projection simplification + relies on flat axis-aligned faces. + * Semi-periodic BCs (e.g., XY periodic, Z Dirichlet). The full- + periodic assumption simplifies the corner Dirichlet handling; + semi-periodic adds new corner / edge classifications. + * Hanging-node (h-refinement) non-conformity. MFEM has its own + machinery for hanging nodes; we should not re-implement it. + Our scope is ONLY non-matching subdivisions on the + user-supplied original mesh. + +**What stays unchanged:** + * The Wohlmuth corner / edge dual-basis modifications + (`MQuad4DualModified`, `MTri3DualModified`) — they depend on + `boundary_tag` (set by the classifier from sentinel patterns), + not on the integration domain. They evaluate at any (ξ, η) / + barycentric point. + * The boundary classifier's sentinel-driven `boundary_tag` + classification (`ClassifyQuadBoundaryTag`, + `ClassifyTriBoundaryTag`). + * The Method-D corner Dirichlet logic (Lopes et al. 2021 §3.4). + * `MortarConstraintOperator` (Phase 4.3 EA path). + * `MortarSaddlePointSystem`, `SaddlePointSolver`. + * The GPU port (Phase 4.3.B). The `BuildFlatRowArrays` walk + consumes `FaceMortarPairBlock` regardless of whether the + block came from the conforming or clipped path. + * The `FaceMortarPairBlock` data layout itself (D vector, + A_m sparse matrix, gtdof arrays). + +**Architectural seam:** all non-conforming work is contained in +three places. The rest of the pipeline is untouched. + 1. New `AssemblePairClipped` method on the face-mortar + assemblers (sibling to `AssemblePairConforming`). + 2. New `MatchClippedFacePairs` helper (sibling to + `MatchConformingFacePairs`). + 3. Small dispatch decision in + `BoundaryClassifier3D::BuildLocalPairBlocks`: try + `MatchConformingFacePairs` first; on a non-1:1 match count, + fall back to `MatchClippedFacePairs`. + +##### Algorithmic invariants from the existing 2D code + +The 2D non-conforming case is fully solved (`mortar_assembler_2d` +in C++, `mortar_pbc/mortar_2d.py` in Python). The 3D face-mortar +non-conforming case must extend the **same** pattern — anything +that diverges from this pattern is a bug. + +**The D-vs-A_m domain split.** This is implicit in the 2D code +(line 326 of `mortar_2d.py`) but not explicitly called out in +the architecture doc. It is the central principle: + + * **D contributions** are accumulated PER NONMORTAR ELEMENT, + with the integration domain being the FULL nonmortar element: + `D_k += ∫_{full_nonmortar_element} N_k dA = phys_jacobian * w_q * N_k(xi_q)` + summed over canonical quadrature points on the full nonmortar + reference element. **D never sees the clipped sub-polygon.** + + * **A_m contributions** are accumulated PER CLIPPED OVERLAP, + with the integration domain being the OVERLAP polygon: + `A_m[k,l] += ∫_{overlap} M_k(xi_nm) * N_mortar_l(xi_m) dA` + summed over a per-sub-triangle quadrature on the clipped + sub-polygon's fan triangulation. **A_m always sees the + clipped overlap, never the full element.** + +Why this split is correct: Wohlmuth's biorthogonality identity +`∫_E M_i N_j dE = δ_ij ∫_E N_i dE` holds when integrated over +the full element E, NOT segment-wise. So we compute D directly +as `∫_E N_i` (a cheap element-local quadrature) rather than as +`∑_segments ∫ M_i N_i` (which would compound rounding error and +require correctly summing all overlapping segments' contributions). + +The 2D code uses `D_nm[k] += plus_jacobian` directly (the +analytic value of `∫_{line2} N_k dxi · J = J = phys_half_length` +for each endpoint k=1,2). The 3D conforming code already does +the equivalent: `D_loc[k] += phys_w * N_nonmortar[k]` summed over +canonical quadrature points on the full nonmortar element. **The +non-conforming version reuses this loop verbatim.** Only the +A_m loop changes. + +**The mortar inverse map is local-affine for our scope.** For +axis-aligned grids: + * Quad face (Q1): the bilinear isoparametric map collapses to + an affine map `xi = 2*(a - a_lo)/(a_hi - a_lo) - 1` per + parametric direction. Inverse is two scalar divisions. + No Newton iteration needed. + * Tri face (P1): the affine isoparametric map has a 2×2 inverse; + closed-form via Cramer's rule. + +The architecture doc §11.6 spells this out; the existing +`face_mortar_assembler_3d.cpp` does NOT need this because its +conforming path uses `MortarRefFromPermutation` (a permutation +of nonmortar local coords), but the non-conforming path will +need the explicit inverse map. + +##### Decisions and refinements + +These are the design decisions for the 3D non-conforming case. +The literature review (Bernardi-Maday-Patera 1994, Wohlmuth +2000, Puso-Laursen 2004, Popp-Wohlmuth-Gee-Wall 2010, Farah- +Popp-Wall 2015, Sitzmann-Willner-Wohlmuth 2016, Lopes et al. +2014/2021, Reis & Andrade Pires 2014, Rodrigues Lopes et al. +2021, Mayr-Popp 2022) confirms the architecture doc's planned +approach with two refinements: use Axom's primitives where +available, and bump the per-clipped-sub-triangle quadrature +order for quad-face overlaps. + +**Decision 1: Polygon clipping via `axom::primal::clip`.** The +architecture-doc §3.7 recommends hand-rolled Sutherland-Hodgman. +Axom (LLNL's mesh-processing library) provides +`axom::primal::clip` for 2D-polygon-on-2D-polygon convex-on-convex +clipping with documented robustness work (release notes mention +specific fixes for clip robustness). Since Axom is being added +to ExaConstit anyway for restart support (Sidre), and since +hand-rolled clipping has a long tail of degenerate-vertex / +near-collinear-edge cases, **use Axom's clip rather than +hand-rolling**. The architecture doc's §3.7 pseudocode stays as +the algorithmic reference; the implementation is a thin wrapper +around `axom::primal::clip`. + +**Decision 2: Point location via `axom::spin::BVH<2>`.** The +architecture doc §11.6 specifies "AABB-tree-or-similar lookup" +through a `spatial_index.locate(plane_coords)` interface. +`axom::spin::BVH` provides exactly this, parameterized +on dimension. Use `axom::spin::BVH<2>` keyed on the 2D-projected +AABBs of the mortar elements. + +This is GPU-portable through Axom's RAJA-based execution model; +that aligns with the Phase 4.3.B GPU work but is not required +for Phase 4.4 (the BVH query is setup time, not hot path). + +**Decision 3: Hand-rolled inverse maps.** Don't use Axom for the +parametric-coordinate inverse maps (Q1 affine bilinear, P1 tri +affine). They're 5-line closed-form formulas; pulling in a more +heavyweight inverse-isoparametric utility is overkill. + +**Decision 4: Per-sub-triangle quadrature order.** + +The architecture doc §11.9 question 3 sets the conforming-case +quadrature: 4-point Gauss for quad, 3-point Dunavant for tri. +For non-conforming on **clipped sub-triangles**, the integrand's +polynomial degree on the sub-triangle's barycentric coordinates +must be re-counted because the integration domain changes: + + * **Tri face (P1) on clipped sub-triangle.** Both `M^mod(λ_nm)` + and `N_mortar(λ_m)` are linear in their respective + barycentric. Under the affine (λ_nm → λ_m) sub-affine map + on the sub-triangle, `M·N` is degree 2 in the sub-triangle's + barycentric. **3-point Dunavant (degree 2) suffices.** Same as + the conforming case. + + * **Quad face (Q1) on clipped sub-triangle.** `M^mod(ξ_nm, + η_nm)` is bilinear in (ξ, η). After mapping to the + sub-triangle's barycentric (which substitutes piecewise-linear + expressions for ξ and η), bilinear-times-bilinear becomes + degree 4 in barycentric. **6-point Dunavant (degree 4) + suffices.** This is a deviation from the conforming case + (which used a 9-point tensor-product rule on the un-clipped + parent quad reference, equivalent to degree 5 in (ξ, η)). + +The Wohlmuth-modified bases on edge-adjacent or corner-adjacent +elements have lower polynomial degree (constant in the corner- +adjacent case; mixed constant + linear in the edge-adjacent +case), but per architecture doc §11.9 question 3 we use the +"safe uniform rule" policy: 6-point Dunavant on every quad-face +sub-triangle, 3-point Dunavant on every tri-face sub-triangle, +regardless of `boundary_tag`. + +**Decision 5: Conforming fast path is preserved.** When +`MatchConformingFacePairs` returns a clean 1:1 partition (every +nonmortar element has exactly one mortar partner), the existing +`AssemblePairConforming` runs unchanged. The clipped path is +opt-in based on the matching result. Concretely: + * `MatchConformingFacePairs` now returns + `optional>` instead of asserting on + non-1:1: `nullopt` signals "fall back to clipped path." + (Or equivalently: a separate + `TryMatchConformingFacePairs` that returns an optional.) + * `BuildLocalPairBlocks` calls `TryMatchConformingFacePairs`; + on `nullopt`, calls `MatchClippedFacePairs` and + `AssemblePairClipped`; otherwise calls + `AssemblePairConforming`. + +**Decision 6: D contribution stays in `AssemblePairConforming`- +style code.** Both `AssemblePairConforming` and +`AssemblePairClipped` factor the D accumulation into a shared +helper `AccumulateNonmortarD(D_loc, nonmortar_elem)` that walks +the canonical nonmortar quadrature once and contributes +`phys_w * N_k(xi_q)` per node. The clipped path's outer loop +calls this helper once per nonmortar element BEFORE the inner +clipped-sub-triangle loop (which only touches A_m). This +preserves the D-vs-A_m domain split as a structural property of +the code, not a comment. + +##### Detailed batch sequence + +The work breaks into 5 batches plus an architecture-doc +clarification batch (4.4-0). Each batch has a clear validation +gate. + +| Batch | What | Why | Validation | +|---|---|---|---| +| 4.4-0 | Architecture-doc clarification: explicitly document the D-vs-A_m domain split in §3.5 / §3.7 (currently only implicit in the 2D code). | Future readers (and Claude in future sessions) shouldn't have to reverse-engineer this from the 2D code. | Doc-only; no code change. | +| 4.4-A | Add Axom to the build. CMake integration via BLT, find_package(axom REQUIRED), pin a version, validate by compiling a no-op sandbox file that includes `` and ``. Document the new dependency in the build instructions. | Foundational; without Axom, the rest of the work is hand-rolled. | Sandbox file compiles; no behavioral changes; existing tests pass. | +| 4.4-B | `MatchClippedFacePairs` for quad. Builds an `axom::spin::BVH<2>` over the mortar elements' 2D-projected AABBs (drop the perpendicular axis). For each nonmortar element, queries the BVH to get candidate mortar elements whose AABBs overlap; emits a list of `(s_idx, m_idx)` candidate pairs. No clipping yet. | Broad-phase first. Decouples spatial-search correctness from clipping correctness. | Unit test on a synthetic 4×4 nonmortar / 5×5 mortar pairing: every nonmortar element gets ≥1 candidate; total candidate count is in expected range (about 4×4 × ~4 ≈ 64 pairs). | +| 4.4-C | Polygon clipping for the candidate pairs (quad + tri). Wraps `axom::primal::clip` with our `(a, b)` 2D-projection convention. For each candidate pair, produces a clipped polygon (or empty), then fan-triangulates into sub-triangles. Returns a flat list of `ClippedSubTriangle { s_idx; m_idx; verts_ab[3]; }`. | Geometry-only; no integration yet. | Unit test: total sub-triangle area equals nonmortar face area to roundoff (tile-cover invariant). | +| 4.4-D | `AssemblePairClipped` for quad and tri. Outer loop over nonmortar elements (calls `AccumulateNonmortarD`). Inner loop over sub-triangles owned by this nonmortar element (per-sub-triangle Dunavant quadrature, evaluates M_dual at xi_nm, N_mortar at xi_m via the closed-form inverse maps, accumulates into A_m). Produces `FaceMortarPairBlock`. | Algorithmic core. | (a) Unit test: a deliberately-conforming 4×4 vs 4×4 setup goes through the clipped path and produces a `FaceMortarPairBlock` numerically equal (within roundoff) to `AssemblePairConforming`'s output. This exercises the full clipped pipeline on a known-correct case. (b) Patch-test driver with non-matching subdivisions (4×4 vs 5×5): constant-strain reproduction to roundoff (`||du||_inf < 1e-12 * scale` for a homogeneous RVE under macroscopic F). | +| 4.4-E | Dispatch in `BuildLocalPairBlocks`: try `MatchConformingFacePairs`, fall back to `MatchClippedFacePairs` + `AssemblePairClipped`. New patch-test executable `test_patch_3d_pbc_nonconforming.cpp` with non-matching subdivisions. CMake registration. | End-to-end integration. | (a) Existing patch tests pass unchanged (regression check — confirms the conforming fast path still kicks in when meshes match). (b) New non-conforming patch test: homogeneous, strip, checkerboard patterns at np=1, 4, 7 with non-matching subdivisions on opposite faces. Constant-strain reproduction to 1e-12; ⟨F⟩ ≈ F_macro to 1e-9. | + +##### Validation strategy details + +**Conforming-path-via-clipped sanity test (Batch 4.4-D part a).** +Take a 4×4 vs 4×4 conforming setup. Force the clipped path via +a flag (or by modifying the dispatch). Each nonmortar element +clips against exactly one mortar element; the clipped polygon is +the full nonmortar quad; fan-triangulation gives 2 sub-triangles +per quad. The integration sums to the same `FaceMortarPairBlock` +as `AssemblePairConforming` modulo FP-rearrangement (which the +6-point Dunavant rule controls — the rearrangement is small). + +This test catches: + * Sign errors in the inverse-isoparametric maps. + * Orientation bugs in the (a, b) projection (CCW invariant). + * Sub-triangle area vs Jacobian inconsistencies. + * Off-by-one errors in the sub-triangle → quadrature-point map. + +**Non-conforming patch test (Batch 4.4-E).** Homogeneous RVE +(uniform material) under macroscopic F. The expected fluctuation +is u_tilde ≡ 0 throughout, so any non-zero u_tilde signals a +mortar implementation bug. Tolerance: `||du||_inf < 1e-12 * +characteristic_length`. The strip and checkerboard variants test +genuine non-zero fluctuation; agreement should be to the +saddle-point solver's Krylov tolerance (1e-7). + +**A/B comparison (optional).** If we want extra confidence, +extend `test_patch_3d_pbc_ea_compare` to accept a non-matching +mesh option and run the EA path through both the conforming and +clipped code branches (with the clipped branch forced even on +conforming meshes). Both should produce the same du to +FP-rearrangement. + +##### Known risks and what to watch for + + * **Dual-basis biorthogonality does NOT hold sub-region-wise.** + The Wohlmuth identity holds when integrated over the FULL + nonmortar element, not segment-by-segment. Our D-vs-A_m + domain split sidesteps this (D is computed on the full + element). If anyone is tempted to "simplify" by computing D + as `∑_segments ∫ M_k N_k`, they'll re-introduce the issue we + explicitly avoid here. Documented in §3.5 / §3.7 by Batch + 4.4-0. + + * **The conforming fast path must still be available** + for performance-critical workloads. Don't replace + `AssemblePairConforming` with `AssemblePairClipped`. + + * **`MatchConformingFacePairs` currently aborts on non-1:1 + matches.** Convert this to a try-style API + (`std::optional` return) so the dispatch can fall back to + clipped without a fatal error. + + * **Cross-rank correctness.** The classifier's tile partitioning + + AllGather is unchanged; the new code lives inside + `BuildLocalPairBlocks` which already runs tile-locally and + contributes to the AllGather'd pair-block list. So + cross-rank should "just work," but the np=4 / np=7 patch + tests should explicitly verify this. + + * **The Wohlmuth `boundary_tag` classification is set on the + nonmortar elements, NOT on the clipped sub-triangles.** All + sub-triangles owned by one nonmortar element share the same + `boundary_tag`. The dual basis evaluation `MQuad4DualModified` + at a non-canonical (ξ_nm, η_nm) — e.g., a quadrature point + inside a sub-triangle that doesn't touch the parent quad's + canonical reference points — must give the correct value. + Looking at the code, `MQuad4DualModified` is a closed-form + polynomial in (ξ, η); it works at any point. ✓ + + * **Tolerance at strongly-mismatched refinement (e.g., 1:10)** — + the Krylov solver's Schur-complement preconditioner can lose + diagonal dominance at very high refinement-ratio. Mayr-Popp + (2022) document this for contact problems and recommend + aggregation-based AMG. For our 1:2 to 1:5 typical case, + block-Jacobi (the existing preconditioner) is fine. If a + user pushes beyond 1:5, document the limitation in the + ConstraintBuilder3D class doc. + +##### What to do at start of work + +When picking up this work cold, the order is: + + 1. **Re-read this section (§P4.4.6.10) end-to-end.** + 2. **Re-read architecture doc §3.5, §3.6, §3.7, §11.6.** + 3. **Re-read `mortar_2d.py:_assemble_pair` and + `_integrate_overlap_segment`** — this is the proven design + template. + 4. **Re-read C++ `face_mortar_assembler_3d.cpp:AssemblePairConforming`** + for both quad and tri — this is the existing structure to + extend. + 5. **Verify host-only Mac build is still green** before + starting any new work. + 6. **Start with Batch 4.4-0** (architecture-doc + clarification). It's a doc-only change that takes 30 + minutes and immediately captures the D-vs-A_m insight in + a place where future readers will find it before the code + gets confusing. + +##### Cross-references + + * Architecture doc §3.5 — geometric matching algorithm. + * Architecture doc §3.6 — conforming "free pass" case. + * Architecture doc §3.7 — Sutherland-Hodgman pseudocode (the + algorithmic specification for what `axom::primal::clip` does). + * Architecture doc §5.2, §5.3 — Wohlmuth modifications for + tri-3 and quad-4 (unchanged in this phase). + * Architecture doc §11.6 — face mortar geometric matching + (with `locate_mortar` interface that BVH provides). + * Architecture doc §11.9 question 3 — quadrature order policy. + * Architecture doc §11.9 question 4 — clipping recommendation + (now refined to Axom rather than hand-rolled). + * Phase doc §P4.4.6.4 — Phase 4.3 batch sequence (this + section is the Phase 4.4 sibling). + * Phase doc §P4.4.6.9 — Phase 4.3.B current state and next + steps (sibling pattern: each phase has a state-and-plan + section). + * Lopes et al. CMAME 384 (2021) — the Method-D corner + Dirichlet derivation; unchanged here. + * Reis & Andrade Pires CMAME 274 (2014) — the foundational + paper for mortar-PBC homogenization (corner-prescribed + Dirichlet approach). + +### §P4.4.7 Saddle-point solver + +The Python prototype's `SaddlePointSolver` wraps MFEM's +`BlockOperator` with one of three Krylov solvers, selected at +construction time. The C++ version mirrors this exactly. CG is +explicitly REJECTED because the saddle-point system is indefinite. + +#### Krylov choice: MINRES, GMRES, BiCGStab + +The three options and when to pick them: + +**MINRES** — `mfem::MINRESSolver`. The default. Optimal for +symmetric saddle-point systems: requires only K to be symmetric +(which it is for linear elasticity and for the symmetric tangent +of finite-strain elasticity), uses short-term Lanczos recurrence +(2 vectors of state regardless of iteration count, vs GMRES's +restart-length-many vectors), and produces monotonically decreasing +residual norm. **Use this whenever K is symmetric.** + +The Lanczos-breakdown concern from my earlier note is overstated: +PA/EA roundoff doesn't break MINRES in practice on saddle-point +systems unless K's symmetry is broken at a level large compared to +the Krylov tolerance, which doesn't happen for elasticity. The +Python prototype defaults to MINRES and it has worked correctly at +every scale tested. + +**GMRES** — `mfem::GMRESSolver`. The fallback for genuinely non- +symmetric K. Use when: +- The material tangent is non-symmetric (e.g., crystal plasticity + with kinematic hardening, anisotropic elasticity with shear + coupling, certain damage models). +- K is FA-assembled with a numerical perturbation that makes its + symmetry break to ~ machine epsilon × condition_number. +- We're debugging and want a more robust default to isolate + Krylov vs solver-correctness issues. + +GMRES needs a restart length (`SetKDim`). For moderate-sized +saddle-point systems use the default of 50; bigger systems may +benefit from 100 or higher at the cost of memory. + +**BiCGStab** — `mfem::BiCGSTABSolver`. The third option. Use when: +- K is non-symmetric AND the GMRES restart length is constrained + by memory. +- We want a short-recurrence non-symmetric solver and accept the + potential for breakdown / non-monotonic residual norm. + +BiCGStab uses constant memory (~7 vectors of state) regardless of +iteration count, unlike GMRES which grows. For very large +problems where GMRES memory is a concern this becomes attractive, +but residual-norm non-monotonicity makes it harder to debug +convergence problems. + +The Python prototype guidance (verbatim, applies to C++): + +> CG is rejected with a clear error message: the system is +> indefinite (zero block in the (2,2) position) and CG diverges +> on indefinite systems. Use MINRES (symmetric K) or GMRES (non- +> symmetric K) instead. + +#### Solver selection API + +```cpp +enum class KrylovKind { MINRES, GMRES, BiCGStab }; + +class SaddlePointSolver { +public: + struct Options { + KrylovKind solver = KrylovKind::MINRES; // default symmetric + std::string preconditioner = "block_jacobi"; // or "block_amg" + double rel_tol = 1e-10; + double abs_tol = 1e-12; + int max_iter = 500; + int print_level = -1; + int gmres_kdim = 50; // GMRES only + }; + + SaddlePointSolver(Options opt = {}); + + // [collective on K's communicator, typically WORLD] + void SolveStep(mfem::Operator& K_op, + mfem::Operator& C_op, mfem::Operator& CT_op, + const mfem::Vector& r1_world, + const mfem::Vector& r2_world, + mfem::Vector& du_world, mfem::Vector& dlam_world); + // ... +}; +``` + +The CLI surface in the validation drivers exposes this as +`--solver={minres,gmres,bicgstab}` — matching the Python flag. + +#### Block-Jacobi at large scale + +MFEM's `BlockDiagonalPreconditioner` uses `Operator::AssembleDiagonal` +to build the diagonal of K (and identity for the multiplier block +in our setup). This works for K-as-PA/EA and K-as-FA uniformly. + +For ~1M+ DOFs the diagonal of K is no longer a sufficient +preconditioner. The standard fix is `HypreBoomerAMG` on the K +block. This is **FA-only** (PA mode would need the +`LORDiscretization` shim), but fine for Phase 4 since K is FA in +Phase 4.1+4.2 anyway. + +```cpp +// Phase 4.1+4.2: BoomerAMG on K, identity on λ. +class SaddlePointPreconditioner : public BlockDiagonalPreconditioner { +public: + SaddlePointPreconditioner(HypreParMatrix& K, + const Array& block_offsets) { + K_amg_ = std::make_unique(K); + K_amg_->SetSystemsOptions(/* dim */ 3); // vdim awareness + SetDiagonalBlock(0, K_amg_.get()); + SetDiagonalBlock(1, &lam_identity_); + } +private: + std::unique_ptr K_amg_; + IdentityOperator lam_identity_; +}; +``` + +The `SetSystemsOptions(3)` call is critical for elasticity: it tells +BoomerAMG that the FE space has 3 unknowns per node and to coarsen +node-wise rather than DOF-wise. Without it, BoomerAMG's coarsening +fragments the displacement components and convergence is poor. + +For Phase 4.3 (PA mode) the FA-only `HypreBoomerAMG` becomes +unsuitable; replace with an LOR-based AMG via +`mfem::LORDiscretization`. Out of scope for Phase 4.1; flagged +here for Phase 5+. + + + +### §P4.4.8 ParaView output + +Direct port of `PbcVisualizationWriter`. MFEM provides +`mfem::ParaViewDataCollection` natively, so this is much shorter in +C++ than in Python (no manual XML writing). Multi-cycle output for +multi-step ramps is built in. + +The mesh-warp + warp-restoration discipline (mortar §9) carries over +verbatim — `RestoreOriginalCoords()` after each `WriteCycle()` is +non-negotiable. + +--- + +## §P4.5 Test driver porting plan + +Three drivers, ported in order: + +### `examples/patch_test_3d_pbc.cpp` (Phase 4.1.A) + +Port of `examples/patch_test_3d_pbc.py`. Single load step, homogeneous +linear-elastic. Fluctuation u_tilde = 0 to machine precision. + +PASS criteria identical to Python: +- Krylov converged +- ||du||_inf < 1e-7 +- || - F_macro|| < 1e-9 +- ||C·u_total - C·u_lin|| < 1e-9 + +This is the **load-bearing milestone**. If it passes at np=1, 4, 16 +hex+tet, the infrastructure (BoundaryClassifier3D, ConstraintBuilder3D, +saddle-point solver) is correct. + +### `examples/patch_test_3d_heterogeneous.cpp` (Phase 4.1.B) + +Port of `examples/patch_test_3d_heterogeneous.py`. Strip-split +heterogeneity, multi-step ramp, PWConstCoefficient on Lame parameters. + +PASS criteria identical to Python (mortar §3 of het driver): +- Krylov converged +- ||C·u_tilde||_2 < 1e-8 +- ||u_tilde||_inf > 1e-12 (**must be non-zero**) +- | - F_macro|_max < 1e-9 + +### `examples/patch_test_3d_checkerboard.cpp` (Phase 4.1.C) + +Port of `examples/patch_test_3d_checkerboard.py`. 2x2x2 octant XOR, +maximum-stress test for the constraint machinery (every matched +element pair crosses a material interface). + +PASS criteria identical to heterogeneous. + +--- + +## §P4.6 Validation strategy + +### §P4.6.1 Bit-comparison with Python + +For Phase 4.1 we want **bit-identical numerical answers** between +C++ and Python at np=1 hex, n=4 mesh. + +Mechanism: +1. Add a Python-side debug flag that serialises the assembled C + matrix (CSR triples), `u_lin`, the saddle-point RHS, and the + final solution `du` to `.npy` / `.txt` files. +2. Add a C++-side debug flag that does the same. +3. Diff the files. Tolerance: floating-point identity for `C` (it's + built from rational dual basis values), 1e-12 for solution + vectors (Krylov tolerance dominates). + +This is the gold-standard regression test. Any mismatch exposes a +bug in the C++ implementation. + +### §P4.6.2 Per-class unit tests in C++ + +Mirror of the Python test suites: +- `test_mortar_3d_unit.cpp` — dual basis values (Phase 3.2.A). +- `test_face_mortar_3d.cpp` — dense block correctness (Phase 3.2.B). +- `test_edge_mortar_3d.cpp` — edge mortar reuse (Phase 3.3.A). +- `test_boundary_classifier_3d.cpp` — topology helper tests (3.3.B). +- `test_constraint_builder_3d.cpp` — sparsity + nullspace (3.3.C). + +Use Catch2 or GoogleTest depending on ExaConstit's existing +convention. Each test file mirrors one Python suite and has the +same number of assertions. + +### §P4.6.3 Scaling validation matrix (Phase 4.2) + +Once Phase 4.2 (tile-partitioned matching) is in: + +| n | global zones | global TDOFs | nranks tested | expected status | +|-----|-------------:|-------------:|----------------------|-------------------| +| 4 | 64 | 375 | 1, 4, 16 | machine-precision | +| 8 | 512 | 2187 | 4, 16, 64 | machine-precision | +| 16 | 4 096 | 14 739 | 16, 64 | machine-precision | +| 32 | 32 768 | 107 811 | 64, 256 | machine-precision | +| 64 | 262 144 | 823 875 | 256, 1024 | machine-precision | +| 128 | 2 097 152 | 6 440 067 | 1024, 4096 | scaling check | +| 256 | 16 777 216 | 50 923 779 | 4096, 16384 | scaling check | + +The "machine-precision" threshold should hold at any nranks count +because the algorithm is deterministic modulo MPI reduction order; +deviations indicate a load-imbalance or numerical-roundoff issue +worth investigating. + +The "scaling check" rows are about wall-time; PASS criteria stay +the same but we expect to see Caliper data showing classifier setup +< 5% of total runtime, mortar integration < 1%, saddle-point solve +~80%+ (the right place for time to go). + +### §P4.6.4 Caliper instrumentation + +ExaConstit convention: `CALI_CXX_MARK_SCOPE("name")` at the top of +every method that does non-trivial work. Names: + +``` +mortar_pbc::classifier::compute_bbox +mortar_pbc::classifier::discover_face_label_by_attr +mortar_pbc::classifier::gather_boundary_records [Phase 4.1] +mortar_pbc::classifier::tile_partitioned_match [Phase 4.2] +mortar_pbc::classifier::build_corners +mortar_pbc::classifier::build_edges +mortar_pbc::classifier::build_faces +mortar_pbc::face_mortar::integrate_pair +mortar_pbc::edge_mortar::integrate_pair +mortar_pbc::constraint_builder::build_hypreparmatrix [Phase 4.1] +mortar_pbc::constraint_builder::build_ea_operator [Phase 4.3] +mortar_pbc::driver::solve_step::assemble_K +mortar_pbc::driver::solve_step::saddle_point_krylov +mortar_pbc::driver::solve_step::compute_F_average +mortar_pbc::visualization::write_step +``` + +Output goes through Caliper's existing ExaConstit configuration (the +`*.cali` files); we don't need to add new infrastructure. + +--- + +## §P4.7 Phasing roadmap + +``` +Phase 4.1 — Initial port (AllGather, HypreParMatrix C) +├── 4.1.A patch_test_3d_pbc.cpp + four core classes +│ Validate at np=1, 4, 16 hex+tet. +│ Bit-comparison vs Python at np=1. +├── 4.1.B patch_test_3d_heterogeneous.cpp +├── 4.1.C patch_test_3d_checkerboard.cpp +└── 4.1.D Per-class unit tests (5 test suites). + All sandbox-equivalent of Python tests passing. + + ↓ (gate: all of 4.1.A-D green) + +Phase 4.2 — Distributed-hash matching +├── 4.2.A Refactor BoundaryClassifier3D to AllGather-free path. +│ Re-validate 4.1.A-C at np=4, 16, 64. +├── 4.2.B Scaling validation up to np=1024 on test cluster. +└── 4.2.C Caliper-driven profiling, document hot paths. + + ↓ (gate: 4.2.B passes at np=1024 with no surprise hot paths) + +Phase 4.3 — Element-assembly constraint operator (CONFORMING meshes) +├── 4.3.A MortarConstraintOperator class, runtime selectable via +│ --constraint-storage=ea flag. +├── 4.3.B GPU port of EA path (mfem::forall over pairs). +│ First pass DONE: forward Mult on flat arrays + memory- +│ manager annotations; DEVICE_DEBUG-clean. Pending: atomic- +│ add MultTranspose, real CUDA/HIP build validation, +│ performance work. See §P4.4.6.9. +├── 4.3.C A/B validation: hypre vs ea at np=1, 4, 64, 256, identical +│ output to Krylov tolerance. +└── 4.3.D Performance comparison: total wall-time, K matvec time, + C matvec time, peak memory. EA should be no slower than + Hypre on CPU and faster on GPU. + + ↓ (gate: 4.3.C green; 4.3.B atomic-add follow-up + can land in parallel with Phase 4.4) + +Phase 4.4 — Non-conforming face mortar (Phase 3.5 in architecture doc) +├── 4.4.0 Architecture-doc clarification: explicit D-vs-A_m domain +│ split documentation in §3.5 / §3.7. +├── 4.4.A Add Axom dependency (BLT/CMake integration). Validate by +│ compiling a no-op sandbox file. +├── 4.4.B MatchClippedFacePairs broad-phase via axom::spin::BVH<2>. +│ Unit-test the candidate-pair enumeration. +├── 4.4.C Polygon clipping via axom::primal::clip + fan-triangulation. +│ Tile-cover invariant test. +├── 4.4.D AssemblePairClipped (quad + tri). Validate via: +│ (a) conforming-via-clipped sanity test (4×4 vs 4×4); +│ (b) non-conforming patch test (4×4 vs 5×5, homogeneous). +└── 4.4.E Dispatch in BuildLocalPairBlocks; new + test_patch_3d_pbc_nonconforming executable. + Validate at np=1, 4, 7 with strip + checkerboard + non-matching patterns. + + ↓ (gate: 4.4.E green) + +Phase 4 complete. Promote tests/mortar_pbc/ → src/mortar_pbc/. +Move on to Phase 5 (ExaConstit integration: BCManager, SystemDriver, +velocity-primal switch). +``` + +--- + +## §P4.8 Specific implementation hazards + +These are places where I expect to spend disproportionate debugging +time. Worth flagging now so we don't lose days to surprises. + +### §P4.8.1 The byNODES vs byVDIM ordering trap + +Mortar §9.4 documents this for Python. In C++ the trap is just as +real: `mfem::ParFiniteElementSpace` constructed with explicit +`Ordering::byNODES` is required for the prototype's TDOF assumptions +to hold. The constraint matrix's column indices directly use +`fes.GetGlobalTDofNumber(ldof)` returns; if the FES is byVDIM, the +gtdof_x → gtdof_y → gtdof_z stride changes from `+n_scalar` to +`+1` and the constraint expansion silently produces wrong matrices. + +**Mitigation**: assert ordering at FES construction time, document +in class docstrings, write a unit test that builds a small mesh +both ways and verifies the assert fires when byVDIM is used. + +### §P4.8.2 HypreParMatrix lifetime traps + +MFEM #793 (linked in mortar §6.4) describes the SparseMatrix-aliasing +problem when `ParBilinearForm::ParallelAssemble` is called twice. +Solution in the heterogeneous Python driver: build TWO ParBilinearForm +objects, one for `K_full` and one for `K_eliminated`. Carry this +pattern verbatim to C++. + +For the constraint matrix, a related concern: after building `C` via +the HypreParMatrix CSR constructor, the local `SparseMatrix diag` / +`offd` go out of scope. Verify HypreParMatrix has copied (it does, +internally; documented in MFEM source). But DOUBLE-VERIFY at first +construction with a deliberate scope-exit + Mult-and-check. + +### §P4.8.3 Distributed C row-partition correctness + +The nonmortar-DOF-ownership row partitioning assumes that for every nonmortar +node owned by rank r, all the mortar nodes in r's matched mortar row +are reachable (either local-diag or off-process via cmap). This is +true by construction (mortar and nonmortar faces of an axis-aligned RVE +have the same MFEM partition modulo periodic identification), but +NOT verified. + +**Mitigation**: at build time, after constructing C, do a sanity +matvec: pick a deterministic test vector, multiply by C in HypreParMatrix +form, gather the result, compare against a serial reconstruction. Any +mismatch indicates a partitioning bug. Mirror of the +"Operator-correctness diagnostic" in the 2D Python driver +(`patch_test_2d.py` lines 730ish). + +### §P4.8.4 The runtime attribute-discovery cross-rank consistency + +Mortar §11.7.2 documents that MFEM's `MakeCartesian3D` boundary- +attribute ordering varies. The Python `_discover_face_label_by_attr` +runs locally then `comm.allgather`s + checks consistency. In C++: + +```cpp +std::map> local_findings = ...; +// Pack into a flat int buffer for AllGather. +// Each rank sends (n_findings_this_rank, attr0, axis0, extreme0, ...). +std::vector packed = PackFindings(local_findings); +auto all_packed = MpiAllgatherv(packed, comm); +std::map> merged; +for (const auto& rank_findings : all_packed) { + for (const auto& [attr, finding] : rank_findings) { + if (auto it = merged.find(attr); it != merged.end()) { + MFEM_VERIFY(it->second == finding, + "Inconsistent face-label discovery across ranks"); + } else { + merged[attr] = finding; + } + } +} +``` + +**Easy to get wrong**: forgetting the consistency check and using +the first-rank-with-this-attr's finding without verifying other +ranks see the same. Silent bugs follow. + +### §P4.8.5 The "Allgather everything to rank 0" pattern (C-as-CSR) + +In Python, the saddle-point right-hand side construction uses +`g_par = C @ u_lin` where C is a scipy CSR replicated on rank 0. +In C++ with a true distributed C, this is just `C->Mult(u_lin_par, +g_par)` and Hypre handles it. **No allgather of u_lin needed.** +Resist the temptation to port Python's manual pack-unpack style. + +### §P4.8.6 The MFEM IntRule order convention + +Python `mfem.IntRules.Get(geom, order)` where `order = 2 * fe.GetOrder() + 1` +for K assembly. Same convention in C++. For the volume-averaged F +integrand (∇u, piecewise constant on linear elements) we can drop +to `order = 2`; documenting in class so it's clear what each +quadrature is doing. + +### §P4.8.7 Boundary-subcommunicator gotchas + +The boundary subcomm pattern (§P4.4.0) is straightforward in +principle but has several places where bugs hide. + +**Trap 1: forgetting that `boundary_comm == MPI_COMM_NULL` on +interior ranks.** Any call to `MPI_Comm_size(boundary_comm, ...)`, +`MPI_Comm_rank(boundary_comm, ...)`, or any collective on +`boundary_comm` from an interior rank is undefined behaviour +(typically a crash, sometimes a silent hang). Every boundary-comm +operation must be guarded: + +```cpp +if (boundary_comm != MPI_COMM_NULL) { + // boundary work +} +``` + +In the C++ code, the cleanest way to enforce this is to make +`BoundaryClassifier3D` and `ConstraintBuilder3D` only constructible +when the comm is non-null. If construction is itself guarded, all +methods on the resulting object are safe to call without further +checks. + +**Trap 2: mixing WORLD and boundary-comm reductions in the same +function.** For example, the runtime attribute-discovery does its +local check on `boundary_comm` AllGather, but then the result needs +to be Bcast to **interior ranks** so the driver on those ranks +knows the total count of constraint multipliers (needed for the +HypreParMatrix-on-WORLD construction). This requires a separate +WORLD broadcast from a designated boundary-comm root. Forgetting to +do this leaves interior ranks with stale counts and the +HypreParMatrix construction breaks. + +The pattern: + +```cpp +int n_lam_total_world; +if (boundary_comm != MPI_COMM_NULL) { + int my_brank; MPI_Comm_rank(boundary_comm, &my_brank); + if (my_brank == 0) { + n_lam_total_world = ComputeFromBoundaryClassifier(); + } + // Bcast within boundary_comm. + MPI_Bcast(&n_lam_total_world, 1, MPI_INT, 0, boundary_comm); +} +// NOW Bcast to interior ranks via WORLD: every rank participates, +// the boundary-rank-with-the-value broadcasts to all others. +// We need a designated WORLD root — typically world rank 0 if it's +// in boundary_comm, otherwise the lowest world rank that is. +MPI_Bcast(&n_lam_total_world, 1, MPI_INT, designated_root, MPI_COMM_WORLD); +``` + +A simpler alternative when nranks is reasonable: AllReduce on WORLD. +Every boundary rank reports its `n_lam_local`; every interior rank +reports 0; the AllReduce sum is `n_lam_total_world` and arrives on +every rank. + +```cpp +int my_n_lam_local = (boundary_comm != MPI_COMM_NULL) + ? ComputeMyNLamLocal() + : 0; +int n_lam_total_world; +MPI_Allreduce(&my_n_lam_local, &n_lam_total_world, 1, MPI_INT, + MPI_SUM, MPI_COMM_WORLD); +``` + +This pattern is preferred because it doesn't require hunting for a +designated root. + +**Trap 3: re-using a freed boundary_comm.** `MPI_Comm_split` creates +a new communicator that must be freed with `MPI_Comm_free` at +shutdown. If `BoundaryClassifier3D` holds the comm by value and has +its destructor free it, but the driver also tries to free it +later, you get a double-free. + +The cleanest model in ExaConstit is to **store boundary_comm in +the existing `SimulationState` class**, which already owns the +program-lifetime communicators. `SimulationState` owns the lifecycle +(creates the comm at startup, frees it in its destructor); all of +`BoundaryClassifier3D`, `ConstraintBuilder3D`, and `MortarPbcDriver` +take it by reference (`MPI_Comm boundary_comm` from the SimulationState +accessor). No object except `SimulationState` ever calls `MPI_Comm_free` +on it. This matches ExaConstit's existing convention for the few +non-WORLD comms it manages. + +```cpp +// In SimulationState: +class SimulationState { +public: + void InitMortarPbcSubcomm(const mfem::ParMesh& pmesh) { + const int has_boundary = (pmesh.GetNBE() > 0) ? 1 : MPI_UNDEFINED; + int world_rank; + MPI_Comm_rank(MPI_COMM_WORLD, &world_rank); + MPI_Comm_split(MPI_COMM_WORLD, has_boundary, world_rank, + &mortar_pbc_boundary_comm_); + } + MPI_Comm GetMortarPbcBoundaryComm() const { + return mortar_pbc_boundary_comm_; + } + ~SimulationState() { + if (mortar_pbc_boundary_comm_ != MPI_COMM_NULL) { + MPI_Comm_free(&mortar_pbc_boundary_comm_); + } + } +private: + MPI_Comm mortar_pbc_boundary_comm_ = MPI_COMM_NULL; +}; +``` + +This avoids the need for a standalone RAII wrapper class — the +SimulationState lifetime already provides RAII semantics, and we +match the ExaConstit pattern for the handful of non-WORLD comms +that exist today. + +**Trap 4: dynamic load-balancing isn't supported.** If MFEM's +ParMesh repartitions across the run (it doesn't currently for +ExaConstit's flow, but might in the future), the boundary-rank set +changes and the subcomm needs to be rebuilt. For Phase 4 we assume +the partition is static after construction; flag this as a Phase 5+ +concern if/when ExaConstit grows dynamic load balancing. + +### §P4.8.8 Collective MFEM operations inside `if (rank == 0)` print blocks + +Several MFEM accessors that look like cheap scalar getters are in +fact COLLECTIVE operations that issue MPI reductions internally: + +* `mfem::ParMesh::GetGlobalNE()` — Allreduce of local element count. +* `mfem::ParFiniteElementSpace::GlobalTrueVSize()` — Allreduce of + local TDOF count. +* `mfem::ParFiniteElementSpace::GlobalVSize()` — Allreduce. +* Some forms of `HypreParVector::Norml2()` / `Normlinf()` — Allreduce + for the global norm. (`mfem::Vector::Normlinf()` on a TDOF view is + local; only the Hypre-vector forms collective.) + +**The bug pattern**: putting any of these inside a rank-0-only print +block: + +```cpp +if (rank == 0) +{ + std::cout << "global TDOFs = " << fes.GlobalTrueVSize() << ...; +} +``` + +Only rank 0 enters the Allreduce; the other ranks proceed past it. +The next collective on the other ranks then consumes rank 0's stale +Allreduce — different `count`, different datatype — and you get +`MPI_ERR_TRUNCATE` (or worse: a silent stall on a buffered transport). + +**Mitigation**: always call collectives on every rank, then print +the cached scalar inside the conditional. + +```cpp +const int n_global_tdofs = fes.GlobalTrueVSize(); // collective — all ranks +if (rank == 0) +{ + std::cout << "global TDOFs = " << n_global_tdofs << ...; +} +``` + +This is invisible at np=1 (which is why it slipped through in the +patch-test driver's first cut) and only manifests at np ≥ 2. Code +review checklist: every `if (rank == 0)` block must be audited for +this; in particular any line of the form `<< some_par_thing.Method()` +inside the block is suspect. + +### §P4.8.9 Parallel matrix column partitions must align with the FES TDOF partition + +When constructing a `mfem::HypreParMatrix` whose columns correspond +to FES true-DOFs (e.g. the constraint matrix C, whose columns +multiply against displacement TDOF vectors), the column partition +MUST be taken from `fes.GetTrueDofOffsets()`, NEVER computed as a +uniform chunk split. + +**The bug pattern**: + +```cpp +// WRONG — uniform chunk split that does not match FES partition +const HYPRE_BigInt chunk = n_global_cols / nranks; +const HYPRE_BigInt my_chunk = chunk + (rank < rem ? 1 : 0); +// ... +col_starts[0] = my_start; +col_starts[1] = my_start + my_chunk; +``` + +The FES's actual TDOF partition is determined by **METIS partitioning +of the mesh**, not by uniform chunks. For a 4×4×4 hex mesh at np=4, +typical METIS yields {90, 90, 60, 135} TDOFs per rank, while uniform +chunking would give {94, 94, 94, 93}. The matvec `C·u` then aborts +with `C.Width() != K.Height()` inside `BlockOperator::Mult` — or +worse, on builds without that check, silently produces a wrong-sign +result because Hypre's diag/offd splitting puts entries in the wrong +half. + +**Mitigation**: take the column partition straight from the FES. + +```cpp +HYPRE_BigInt* fes_tdof_offsets = fes.GetTrueDofOffsets(); +col_starts[0] = fes_tdof_offsets[0]; +col_starts[1] = fes_tdof_offsets[1]; +``` + +Same rule for row partitions on matrices whose rows are TDOFs (K +itself, but `ParBilinearForm::ParallelAssemble` handles that +automatically). It only bites for matrices the user constructs +directly via the explicit-CSR `HypreParMatrix` ctor. + +Defensive check at construction: verify +`col_starts[1] - col_starts[0] == fes.GetTrueVSize()` and +`MFEM_VERIFY` on mismatch. Catches FES partition state inconsistency +(e.g., re-partitioning after construction) before it propagates. + +This bug is invisible at np=1 (every partition is trivially +`[0, n_global)` regardless of how it's computed). **Multi-rank +validation is required to catch it** — np=1 unit tests cannot. + +--- + +### §P4.8.10 Tile-decomposed mortar block merge must aggregate by gtdof identity + +When Phase 4.2's tile partition splits a face-mortar pair across +multiple ranks, each rank produces a partial `FaceMortarPairBlock` +covering its tile-local elements. Merging these partial blocks across +ranks **must sum partial rows by gtdof identity** for shared DOFs; +naive concatenation produces multiple rows for the same DOF and gives +a constraint matrix with twice (or quadruple) the correct number of +rows. + +**The bug pattern**: + +```cpp +// WRONG — concatenate rows, ignoring DOF identity +int row_ofs = 0; +for (const auto& p : parts) { + for (int i = 0; i < p.NumNonmortarKept(); ++i) { + merged.nonmortar_gtdofs[row_ofs + i] = p.nonmortar_gtdofs[i]; + merged.D(row_ofs + i) = p.D(i); + // ... A_m row copied as-is + } + row_ofs += p.NumNonmortarKept(); +} +``` + +**Why it's wrong**: with 2×2 tile partitioning of a 4×4 nonmortar +face, the inner-subgrid DOFs sit at the corners of a 3×3 quad pattern. +DOF (2,2) (the center of the inner subgrid) is at the corner of four +face elements — one in each of the four tiles. Each tile-rank produces +a partial block with DOF (2,2) in its `nonmortar_gtdofs` along with +partial `D` and partial `A_m` row contributions (the integral over +just that rank's tile area). Concatenation gives FOUR rows for DOF +(2,2) instead of one summed row, and the constraint matrix's row +count balloons by the sharing factor. + +**Mitigation**: the merge step must (a) build a `gtdof → merged_row` +map by union across rank-blocks, (b) build a similar `gtdof → +merged_col` map for mortar columns, (c) translate each rank-block's +`(i, j)` entries through these maps, and (d) **accumulate** into the +merged `A_m` and `D` instead of assigning. Identical-gtdof entries +across ranks then naturally sum. + +```cpp +// CORRECT — gtdof-keyed merge +std::map nm_gtdof_to_row; +for (const auto& p : parts) + for (int i = 0; i < p.NumNonmortarKept(); ++i) { + const int g = p.nonmortar_gtdofs[i]; + if (nm_gtdof_to_row.find(g) == nm_gtdof_to_row.end()) + nm_gtdof_to_row[g] = nm_gtdof_to_row.size(); + } +// (similar for mortar columns) +// then for each rank-block, look up (i, j) → (mr, mc) and ACCUMULATE +out.D(mr) += p.D(i); +out.A_m(mr, mc) += p.A_m(i, j); +``` + +**Mathematical justification**: the integral over a face's mortar +operator decomposes additively over disjoint sub-areas. If element +E1 is in tile A and E2 is in tile B, and both touch nonmortar DOF X, +then \f$\int_{E_1 \cup E_2} N^X \, dA = \int_{E_1} N^X + \int_{E_2} +N^X\f$. The two partial integrals must sum into one row of D and +one row of A_m — not produce two rows. + +The same applies to mortar columns: if mortar DOF Y is touched by +elements in two tiles, both rank-blocks contribute partial entries +to that column. The merge sums them. + +This bug is **invisible at np=1** (only one tile, no merge needed — +the merge function early-returns `parts[0]`). It manifests at np>1 +as a constraint matrix with too many rows and a saddle-point system +that either fails to converge (Krylov breakdown) or converges to a +wrong solution. **Multi-rank validation is required to catch it.** + +The discovery story: the original Batch I implementation used naive +concatenation, with a comment claiming "different ranks' tiles +produce non-overlapping nonmortar gtdofs (they own different tiles) +so simple concatenation is correct." This was wrong. The DOFs at the +**boundaries between tiles** belong to elements in multiple tiles, +and so appear in multiple rank-blocks' `nonmortar_gtdofs` lists. + +The fix is a 30-line replacement of the merge body; the rest of the +tile-shuffle / per-pair-block infrastructure was unaffected. + +--- + +### §P4.8.11 Sparsifying `FaceMortarPairBlock::A_m` is the dominant memory win + +**Lesson**: For conforming face mortars on hex8, `A_m` is **highly +sparse** — each nonmortar row has at most ~16 mortar matches (the +union of mortar nodes from the matched-element pairs touching that +nonmortar node). Storing dense at production scale is the dominant +memory term. + +The arithmetic: at N=100 with three face mortars, dense `A_m` is +roughly `(N²)² × 8 bytes ≈ 800 MB` per face block. Sparse with +`16·N²` nonzeros is ~1 MB. The factor of `N²` reduction is what +unblocks production runs — no other Phase 4.2 change comes close. + +The implementation cost was modest (Batch L, ~400 LOC): + +- `FaceMortarPairBlock::A_m` storage type (`mfem::DenseMatrix` → + `mfem::SparseMatrix`). +- Producer: `AssemblePairConforming` constructs build-mode, calls + `Add()` per integration contribution, `Finalize()` before return. +- Consumer (`ScatterFaceBlock`): walk via CSR `GetI/GetJ/GetData` + rather than `(k, l)` indexing. (`SparseMatrix::operator()(i,j)` is + O(log nnz_row) with binary search, so naive double-loop becomes + O(n_rows · n_cols · log nnz) — much worse than dense. Always walk + CSR.) +- Pack/unpack across MPI: replace dense row-major (`n_n × n_m` + doubles) with sparse CSR (I + J + values, `nnz` doubles). +- Merge across rank-fragments (§P4.8.10): walk source CSR rows, + `Add()` into build-mode merged matrix, `Finalize()` once. + +The `MortarBlock2D::A_m` for **edge** mortars stays dense +deliberately — edge blocks are 1D-coupling with `n_n × n_m ≈ N²`, +not `N⁴`, so dense is fine and the read pattern is simpler. + +**Anti-pattern to avoid**: don't sprinkle `Finalize()` calls +defensively. `Finalize()` is idempotent on already-finalized +matrices, but each pre-Finalize `Add()` followed by a Finalize +followed by another Add forces a CSR-to-build-mode-and-back +conversion that's O(nnz) each time. Build everything you need to +build, THEN Finalize once, THEN read. + +--- + +### §P4.8.12 FES-aligned row partition is what makes AllToAllv routing pay off + +**Lesson**: The asymptotic memory win in Phase 4.2 isn't from +swapping AllGather → AllToAllv in isolation — it's from changing +the **row partition convention** so each block has only a small set +of plausible row owners. Without that, AllToAllv either degenerates +into AllGather (every block must be sent to every potential row- +owner) or requires expensive coordination. + +The two pieces are synergistic: + +1. **AllToAllv-to-row-owner** routing replaces the broadcast of + `m_gathered_pair_blocks` to every rank with a directed exchange + where each rank receives only the blocks contributing to its + rows. Per-rank receive volume drops from O(global_blocks) to + O(global_blocks / n_owners). + +2. **FES TDOF-aligned row partition** assigns row `r` (derived from + nonmortar gtdof `g`) to the rank that owns `g` in FES. This + means the rows from one face-mortar block fragment by the FES + partition: a block whose nonmortar gtdofs span K different FES + owners becomes K fragments routed to K destinations. + +Why FES alignment specifically: + +- The constraint matrix C's column partition MUST already match the + FES TDOF partition (§P4.8.9 — for `C·u` parallel matvec to work, + C's columns must be partitioned IDENTICALLY to K's rows). The + row partition has no such constraint, but FES alignment yields + a useful invariant: **the (row r, col r) "diagonal" entry of C + involves the same gtdof `g` on both sides**, and that gtdof is + on the same rank as both — no off-rank communication for the + diagonal block. +- It avoids the alternative of routing each block's contents to + multiple destinations based on a fair-split of the row range + (which would require a routing layer and lose the FES affinity). + +Implementation steps (Batch N, ~600 LOC): + +- Allgather `FES.GetTrueDofOffsets()[0]` at classifier + construction time → cached `m_fes_tdof_offsets_all`. Add + `GtdofOwnerRank(int gtdof)` doing binary search. +- Replace `GatherPairBlocksAcrossBoundary` with + `RoutePairBlocksToRowOwners`: for each local block, group rows + by `GtdofOwnerRank(nonmortar_gtdofs[k])`, pack one fragment per + destination, `MPI_Alltoallv` on `m_comm` (NOT + `m_boundary_comm` — interior ranks may own the relevant FES + TDOFs). +- Keep the gtdof-keyed merge logic from Batch I/L (§P4.8.10) for + same-bucket fragments arriving at one rank from multiple source + ranks. The merge code is unchanged; only the input source + (Alltoallv result vs Allgather result) differs. +- Filter edge mortar rows in `ScatterEdgeBlock` by + `GtdofOwnerRank(nonmortar_g_xyz[0]) == my_rank`. Edge mortars + are produced redundantly on every rank (cheap 9 small-dense + assemblies), so the filter is a per-row early-`continue`. +- Remove the `n_lam_local` argument from `BuildHypreParMatrix` — + the row partition is now data-determined. Add `NumLocalRows()` + for callers needing the value. + +Subtleties: + +- **At np=1, every gtdof maps to rank 0**, so the routing is + trivial and the test path remains numerically identical to + Batches K/L. This was crucial for keeping the unit-test suite + green during the refactor. +- **A nonmortar gtdof's three components (x, y, z)** can in + principle be on different FES owners, but in MFEM's standard + byNODES vector ordering they cluster on the same rank. The + Batch N code uses the x-component as the row-owner anchor for + consistency between edge and face paths — y and z are sent to + the row owned by x's rank, which costs nothing if they're on + the same rank (typical case) and at worst a small amount of + off-rank column read on `C·u` (if they aren't). +- **Interior ranks may own FES TDOFs that are nonmortar gtdofs of + boundary blocks.** This is why the AllToAllv must run on + `m_comm`, not `m_boundary_comm`. METIS partitioning does not + guarantee co-location of FES TDOF ownership with element + ownership of boundary faces. + +--- + +### §P4.8.13 Use `HYPRE_MPI_BIG_INT`, never a hardcoded width, for `HYPRE_BigInt` MPI exchanges + +**Lesson**: When sending a `HYPRE_BigInt` over MPI, use +`HYPRE_MPI_BIG_INT` as the MPI datatype, NOT a hardcoded +`MPI_LONG_LONG` or `MPI_INT`. `HYPRE_BigInt` is conditionally +typedef'd to `int` (32-bit) or `long long` (64-bit) depending on +HYPRE's `--enable-bigint` configure flag, and `HYPRE_MPI_BIG_INT` +resolves to the matching MPI datatype. Hardcoding the wrong width +silently corrupts the receive buffer. + +**The discovery story** (Batch N first run on Mac at np=7): the FES +TDOF offset Allgather added in Batch N used a hardcoded +`MPI_LONG_LONG`. ExaConstit's HYPRE build has `HYPRE_BigInt = int` +(the default; production rarely needs >2³¹ DOFs). The mismatch +manifested as: + +- Send buffer: one 4-byte `int` containing rank's start offset. +- MPI sends 8 bytes per element (because we said `MPI_LONG_LONG`). +- Receive buffer: `std::vector` (4 bytes per slot). +- MPI writes 8 bytes per slot, **clobbering two adjacent ints**. + +Result: corrupted offset table that fails the monotone-sanity check +with values like "108 -> 0" mid-array. The mistake is easy to make +because: + +1. Sandbox stubs that typedef `HYPRE_BigInt = long long` mask the + bug entirely. +2. At np=1 the mistake doesn't manifest (one element, no + interleaving). +3. At small process counts (2-4) the corruption may not produce + non-monotone values by luck of stack initialization. + +**The fix is one-line**: replace `MPI_LONG_LONG` with +`HYPRE_MPI_BIG_INT` at the call site. There's exactly one place in +the entire mortar-PBC code that exchanges raw `HYPRE_BigInt` over +MPI: the `m_fes_tdof_offsets_all` Allgather in +`BoundaryClassifier3D` ctor. All other MPI-of-long-long uses in the +codebase are `std::vector` pack buffers (gtdofs widened +to long long for portability) — those are genuine `long long`s and +correctly use `MPI_LONG_LONG`. + +**General principle**: any time the data type comes from +HYPRE/MFEM internals (rather than being a deliberate wire format +you control), use the matching MPI macro: +- `HYPRE_BigInt` → `HYPRE_MPI_BIG_INT` +- `HYPRE_Int` → `HYPRE_MPI_INT` +- `mfem::real_t` → `MPITypeMap::mpi_type` (when + MFEM is built with `--enable-single`) + +Sandbox stubs should also reflect this conditional. After this +batch, the stub at `/tmp/mfem_stub/mfem.hpp` defines: + +```c +#ifndef HYPRE_MPI_BIG_INT +#define HYPRE_MPI_BIG_INT MPI_LONG_LONG +#endif +``` + +so future stub-driven sandbox testing matches the real header +behavior. + +--- + +### §P4.8.14 The "row-replicated, fair-split" stepping-stone strategy + +**Lesson**: For a multi-batch refactor that culminates in a +distributed row partition, an intermediate **"every rank produces +the full matrix, then slices its rows"** stage is invaluable. It +keeps the unit-test invariant trivially satisfied (the same C +matrix on every rank means any np=1 test produces exactly the +same numerical output as the eventual distributed code) while +the data-movement infrastructure stabilizes underneath. + +The stepping-stone for Phase 4.2 spanned Batches I → K → L → M: + +- **Batch I**: AllGather all per-pair blocks to every rank. + Every rank produces the full constraint matrix `C` redundantly. + Row partition is fair-split (rank `r` owns rows + `[r·N/P, (r+1)·N/P)`). +- **Batch K**: Same C-on-every-rank invariant; just move the + AllGather from WORLD to boundary_comm + WORLD broadcast fanout. +- **Batch L**: Same invariant; sparsify the per-pair-block storage + to make the AllGather payload tractable at scale. +- **Batch M**: Same invariant at the row-emit layer; refactor + `BuildHypreParMatrix` to skip the intermediate replicated + `SparseMatrix` allocation and filter triples on the fly. + +Then **Batch N** breaks the invariant deliberately: after Batch N, +every rank has only the row-fragments it owns; `Build()` no +longer produces "the full C" but rather "this rank's local row +slice." The unit tests that ran at np=1 continue to work because +at np=1 every gtdof is owned by rank 0 — so "this rank's local +row slice" equals "the full C". + +**Why this matters**: a flag-day refactor that introduces both the +distributed row partition and the AllToAllv routing in one +commit would have left unit tests broken for weeks while bugs +shake out. The stepping-stone strategy keeps every batch +locally testable and makes regressions easy to bisect. + +**Cost paid**: Batches I/K/L/M's redundant work — every rank +producing the full C — adds nontrivial memory and time at large +scale. But: + +1. The existing unit-test suite already runs at np=1, where + redundancy is zero. +2. The patch tests at np=4 stress the redundancy but are tiny + (4³ RVE), so the overhead is acceptable. +3. Production scale (100³+) wouldn't have stayed on the + intermediate stepping-stones anyway — the goal of Phase 4.2 + was always to land at the Batch N design. + +The pattern generalizes: **when you have a distributed-data +refactor that decouples "every rank has every datum" from "every +rank has only its slice", land the supporting infrastructure +first under the redundant invariant, then break the redundancy +in a final focused batch**. The redundant invariant is a powerful +test-fixture: it asserts the new code produces the right answer +without yet committing to the new partition convention. + +**Anti-pattern**: trying to land the row partition change AND +the data-movement refactor AND the storage-type change in one +batch. This breaks unit tests in three different ways +simultaneously and makes regression diagnosis nearly impossible. + +--- + +### §P4.8.15 Refactor a shared inner loop when an overload varies only at one step + +**Lesson**: When adding a function overload that varies only at +one step from the original (here: how `inv_diag_S` is computed — +HypreParMatrix CSR vs EA per-pair walk), the right structural +move is to **extract the shared body into a private helper**, not +to copy-paste 100+ lines of unchanged code into the new overload. + +**The discovery (Batch S)**: The existing +`SaddlePointSolver::Solve(K_hp, C_hp, ...)` had ~125 LOC of body: +dimension checks, `BlockOperator` construction with `K_hp` and +`C_hp` as the (0,0) and (1,0) blocks, `BlockDiagonalPreconditioner` +setup, GMRES/MINRES/BiCGSTAB instantiation, RHS construction, +Krylov solve, solution extraction. The new EA overload +`Solve(K_hp, C_op, ...)` differed only at the preconditioner- +setup line — `BuildInvDiagSchur(C_hp, ...)` becomes +`C_op.ComputeInvDiagSchur(...)`. Everything else is identical +once `C` is typed as `mfem::Operator&` instead of +`mfem::HypreParMatrix&`. + +The temptation was to copy-paste. Two arguments against: + +1. **Maintenance cost**. Any future Krylov-side change (new + `iterative_mode` semantics, additional solver type, alternate + RHS form, different solution-extraction layout) would need to + land in two places. Forgetting one is a silent regression + that may take days to track down. + +2. **Drift risk**. Even if we always remember to update both + places, small differences accumulate over time — one overload + gets a `MFEM_VERIFY` the other doesn't, one's diagnostic + format differs slightly. After a few years there are two + subtly-different solvers. + +The chosen pattern: a private `SolveImplInternal` taking K and C +as `mfem::Operator&` plus pre-computed `inv_diag_K` and `inv_diag_S`. +Each public overload's job shrinks to: +- dimension-check the inputs (overload-specific because the + signatures differ) +- compute `inv_diag_K` and `inv_diag_S` its own way +- delegate to the helper + +The helper is then ~110 LOC, the public `Solve` overloads each +become ~15 LOC, and a future `Solve(K_op, C_op)` for matrix-free +K just plugs in alongside. + +**When NOT to do this refactor**: if the two overloads differ at +many points throughout the body (not just one step), the extracted +helper ends up with so many configuration knobs that it's worse +than two separate functions. The threshold is something like: +"if the helper's parameter list grows beyond ~6 things, two +functions are cleaner." + +**When to apply this lesson**: any time you find yourself about +to add a function overload that diverges from an existing one at +only a small number of identifiable steps. The refactor pays for +itself by the second overload, and the third overload (which +often appears later, e.g., the GPU port in Phase 4.3.B) costs +~15 LOC instead of ~125. + +--- + +### §P4.8.16 Pre-flatten host-side data before chasing `mfem::forall` + +**Lesson**: When porting a CPU implementation that uses `std::map`, +`std::vector`, or other non-GPU-friendly containers in +its hot path, the right first step is **NOT** to wrap the existing +loop in `mfem::forall` — the kernel body would still hit those +containers. The right first step is to **pre-flatten the data at +construction time** into `mfem::Vector` / `mfem::Array` so +the kernel body has nothing but flat array reads. + +**The discovery (Phase 4.3.B / Batch X)**: The CPU `Mult` body +walked `m_local_edge_pairs` (a `std::vector` where +each entry holds a `MortarBlock2D` plus two `EdgeInfo3D` structs) +and `classifier.PairBlocks()` (a similar list). Inside the inner +loop it did `m_gtdof_lookup.find(g_x)` (a `std::map>` lookup) plus `m_import_gtdof_to_slot.find(g_x)` +(another map). None of this can run on a GPU. + +The temptation: turn the outermost `for` into `mfem::forall` and +hope. But the kernel body has to be `MFEM_HOST_DEVICE`, and you +cannot dereference `std::map::iterator` on a device thread — +that's a host-only API. So the kernel won't compile, and even +if it did, the data layout is wrong (struct-of-pointers with +heap-allocated buckets is the worst possible GPU memory pattern). + +The actual fix: build a `BuildFlatRowArrays()` helper that walks +all the per-pair-block data ONCE at construction and produces: + + * `mfem::Vector m_row_D` (one double per row). + * `mfem::Array m_row_csr_off` (prefix-sum row → CSR slice). + * `mfem::Vector m_csr_A` (flat A_kl values). + * `mfem::Array m_csr_g_m_local` / `m_csr_g_m_recv` (paired + tagged-index encoding for off-rank vs. local lookups). + +After this, `Mult`'s kernel body is pure flat-array indexing — +no maps, no struct walks, no host-only APIs — and `mfem::forall` +just works. + +**The cost**: doubled memory for the per-row data (we now have +both the per-pair-block form AND the flat form). At +production-like RVE sizes this is negligible; at toy-test sizes +it's still under a few KB. In return, the matvec hot path runs +on device with a single forall, and DEVICE_DEBUG validates every +memory access. + +**Two adjacent design choices** that came up during this batch: + +1. **The two-array sentinel-free encoding for off-rank lookups**. + The mortar component lookup needs to distinguish three cases: + FES-local, off-rank import buffer, sentinel. Encoding all + three in a single signed int via shifted-negative ranges is + tempting but error-prone (what value is the sentinel? + off-by-one bugs at the encode/decode boundaries). Using two + parallel `Array` arrays (`m_csr_g_m_local` and + `m_csr_g_m_recv`) where exactly one is ≥ 0 (the other being + -1) is more memory but the contract is unambiguous: "if both + are -1 it's a sentinel, otherwise the non-negative one tells + you which buffer to read from." + +2. **Don't try to GPU-ify everything in the same batch**. The + forward `Mult` parallelizes cleanly because each row's output + is unique. `MultTranspose` has many-to-one scatter and needs + atomic adds; `ComputeInvDiagSchur` has cross-rank Allgatherv + followed by sequential accumulation. Doing all three in one + batch triples the surface area of "what could be wrong." + First-pass scope: just the forward direction. The transpose + and the preconditioner setup stay on host with HostRead / + HostWrite annotations (which makes them DEVICE_DEBUG-clean + without changing their algorithmic structure). + +**When to apply this lesson**: any time you have a CPU +implementation full of `std::map` / `std::vector` / raw +pointer arithmetic that you want to GPU-port. The setup-time +flatten is the heavy lifting; the forall conversion afterwards +is mechanical. + +**When NOT to apply**: setup-time methods (called once per +Newton step or once per simulation), where the cost of staying +on host is amortised. `ComputeInvDiagSchur` is in this category; +the matvec hot path is not. + +**See also §P4.8.17** for the companion lesson on what goes wrong +if you DON'T pre-flatten and try to use the existing data +structures directly under `DEVICE_DEBUG` — namely, the +`Vector::GetData()` / `Vector::operator()` traps that fire on +unannotated access to vectors that haven't had their host +validity declared. + +--- + +### §P4.8.17 `Vector::GetData()` and `Vector::operator()` are DEVICE_DEBUG traps + +**Lesson**: Under MFEM's `DEVICE_DEBUG` build, the unsafe back-door +APIs (`Vector::GetData()`, `Vector::operator()`, `Vector::operator[]`) +trigger memory-manager assertions if the host validity flag isn't +already set. The fix is **always** to use the typed accessors +(`HostRead`, `HostWrite`, `HostReadWrite`, or their device +counterparts `Read`, `Write`, `ReadWrite`) in any code that reads +or writes Vector data. These declare access intent so the manager +can validate and migrate appropriately. + +**The discovery (Phase 4.3.B / Batch X)**: the patch driver was +running cleanly in normal builds but failing under `DEVICE_DEBUG` +with: + +``` +Assertion failed: (Empty() || (flags & VALID_HOST)) + --> invalid host pointer access + ... in function: const T *mfem::Memory::operator const double*() const +``` + +The trigger was inside `DiagonalScaler::Mult` (the per-Krylov- +iteration block-Jacobi preconditioner step), which used: + +```cpp +const double* xd = x.GetData(); +double* yd = y.GetData(); +const double* idd = m_inv_diag.GetData(); +``` + +`y` is a sub-vector view that the `BlockDiagonalPreconditioner` +constructs at iteration time. On first use it has no valid host +copy declared. `GetData()` invokes +`Memory::operator const double*()`, which under +`DEVICE_DEBUG` asserts that either the memory is empty or +`VALID_HOST` is set — and at that moment neither is true. + +**The fix is mechanical**: replace `GetData()` calls on Vector +data (and `operator()`, `operator[]` accesses in tight loops) +with the typed accessors. For a read-only loop, hoist a +`HostRead()` pointer above the loop and use it. For a write-only +loop, `HostWrite()`. For accumulation (`+=`), `HostReadWrite()`. + +**Where this matters most**: any Vector that comes from "outside" +the function (function arguments, `GetBlock()` views, freshly- +allocated vectors that haven't been written yet). Vectors that +have just been assigned (`v = 0.0;`, `v = other_vector;`) have +their host validity flag set as a side effect of the assignment, +so subsequent operator() accesses on THOSE vectors don't fail — +but it's still better practice to use a hoisted host pointer for +performance reasons (each operator() call goes through a memory- +manager check on every access). + +**Specific spots fixed in Batch X**: + + * `DiagonalScaler::Mult` — the trigger from the user report. + * `BuildInvDiagK` — invert-diag loop converted to raw pointers. + * `BuildInvDiagSchur` — `MPI_Allgatherv` argument switched to + `HostRead()`; row-sum accumulation and inversion loops + converted to raw pointers. + * `SaddlePointSolver::SolveImplInternal` — RHS construction and + solution extraction loops converted. + * `MortarConstraintOperator::ComputeInvDiagSchur` — the entire + accumulation now goes through a single `sd_data` raw pointer + obtained at function start. + * Patch driver — A/B diff loop, `u_total` recovery loop, + constraint-residual loop, `ComputeVolumeAveragedF` u-copy. + +**For future ports**: as a rule of thumb, any time you write +`for (int i = 0; ...) { v(i) = ...; }` on an `mfem::Vector v`, +rewrite it as: + +```cpp +{ + double* p = v.HostWrite(); // or HostReadWrite, HostRead + for (int i = 0; ...) { p[i] = ...; } +} +``` + +It's no harder to write, runs faster (one memory-manager check +instead of N), and is `DEVICE_DEBUG`-safe by construction. + +**Why not just always use `GetData()` when you know it's host- +local?** Because `GetData()` is the unsafe API — it returns a +raw pointer without registering intent with the manager. Future +maintainers may have no way to know whether your function expects +a host-resident vector or one that might have come from device, +and the inconsistent style invites bugs. The typed accessors are +self-documenting. + +**See also**: + + * §P4.4.6.9 — the full inventory of what's been converted to + typed accessors during the Phase 4.3.B first pass, and what's + still pending. If you're returning to the GPU port work + cold, start there. + * §P4.8.16 — the companion lesson on pre-flattening host-side + data structures before chasing `mfem::forall`. The two + lessons together cover the "how do I make existing CPU code + GPU-ready as a first pass" workflow. + +--- + +### §P4.8.18 Adding Axom as an ExaConstit dependency (Batch 4.4-A) + +The Phase 4.4 non-conforming face mortar work depends on Axom +(LLNL's mesh-processing library) for two specific primitives: +`axom::spin::BVH<2>` (2D bounding-volume hierarchy for spatial +broad-phase) and `axom::primal::clip` (2D-polygon-on-2D-polygon +Sutherland-Hodgman clipping). Axom is also a future dependency +for ExaConstit's restart capability via Sidre, so adding it here +serves both workstreams. + +**Targeted Axom version: v0.14.0** (released 2026-03-31, current +latest at the time of this writing). The API surface we use has +been stable since v0.10.0 with one notable change in v0.12.0: +`AXOM_USE_64BIT_INDEXTYPE` now defaults to `ON`, so +`axom::IndexType` is `std::int64_t` by default (was +`std::int32_t`). This affects declarations explicitly typed as +`axom::IndexType` but not implicit conversions from `int` +literals; our smoke test is written to be IndexType-width- +agnostic. + +**What Batch 4.4-A landed in the test/mortar_pbc tree:** + + * `cpp/test/mortar_pbc/CMakeLists.txt` — adds an + `if(ENABLE_AXOM) list(APPEND EXACONSTIT_TEST_DEPENDS axom) + endif()` block in the optional-package section, paralleling + the existing `ENABLE_CUDA` / `ENABLE_OPENMP` / `ENABLE_HIP` / + `ENABLE_CALIPER` patterns. The `test_axom_smoke` test + registration is also guarded by `if(ENABLE_AXOM)`. + * `cpp/test/mortar_pbc/test_axom_smoke.cpp` — minimal sandbox + test that constructs `axom::primal::Point`, `BoundingBox`, + `Polygon`, calls `axom::primal::clip`, and instantiates an + `axom::spin::BVH<2>`. No functional assertions — its only + purpose is to confirm headers compile and the build system + finds the library. Registered as a single-rank test (no MPI + usage). + +**What's required at the ExaConstit parent level for Axom to +build:** + +The optional-dependency convention used here mirrors the existing +`ENABLE_CALIPER` pattern. Two parent-level pieces are needed: + + 1. **Toolchain or host-config sets `ENABLE_AXOM=ON`** alongside + `axom_DIR` (or `AXOM_DIR`) pointing at the installed Axom + build directory containing `axom-config.cmake`. + 2. **ExaConstit's `cmake/setup_third_party.cmake`** (or wherever + Caliper is currently registered, since the patterns are + parallel) issues: + + ```cmake + if(ENABLE_AXOM) + if(NOT TARGET axom) + find_package(axom REQUIRED CONFIG + HINTS ${AXOM_DIR} ${axom_DIR}) + endif() + # Then register as a known dep so blt_add_executable + # can resolve it from the DEPENDS_ON list: + blt_register_library(NAME axom + INCLUDES ${AXOM_INCLUDE_DIRS} + LIBRARIES axom) + endif() + ``` + + The exact registration call depends on what + `exaconstit_fill_depends_list` and `blt_add_executable` + expect; the existing Caliper plumbing is the model to + follow. + +**Expected build behaviour:** + + * **`ENABLE_AXOM=ON` and Axom found**: `test_axom_smoke` + compiles, links, and runs (exits 0 with one OK line). All + existing tests continue to pass unchanged. + * **`ENABLE_AXOM=ON` and Axom NOT found**: the + `find_package(axom REQUIRED CONFIG)` call at the parent + level fails at CMake configure time — fix `AXOM_DIR` / + `axom_DIR` and retry. + * **`ENABLE_AXOM=OFF`** (or `ENABLE_AXOM` undefined): the + `mortar_pbc_lib` and all conforming-mesh tests still build; + only `test_axom_smoke` (and, in future batches, + `test_patch_3d_pbc_nonconforming`) are skipped silently. The + conforming face mortar code path doesn't link Axom and is + unaffected. This is the correct behaviour for users who only + need the conforming subset. + +**Sandbox / syntax-check workflow.** During development we +maintain a minimal Axom stub at `/tmp/axom_stub/` that mirrors +the API surface we use (`Point`, `BoundingBox`, `Polygon`, +`clip`, `spin::BVH`). The stub returns trivial/empty +results — it's only sufficient for `g++ -fsyntax-only` checks. +Real correctness validation happens against installed Axom on +the user's Mac / cluster. The stub's `IndexType` is hard-coded +to `std::int64_t` to match the v0.12+ default; if a future Axom +build configures with `-DAXOM_USE_64BIT_INDEXTYPE=OFF`, the +stub would be a slight over-promise (real `IndexType` would be +`int32_t`), but the smoke test itself is width-agnostic and +would still compile against either typedef. + +**Cross-references**: + + * §P4.4.6.10 — the Phase 4.4 architectural plan that this + batch is the foundation for. + * Architecture doc §3.7 — Sutherland-Hodgman pseudocode + (which `axom::primal::clip` implements; v0.14.0 release + notes mention "polygon clipping was modified to handle some + corner cases" — purely a robustness improvement, no API + change). + * Architecture doc §11.6 — face-mortar geometric matching + (which `axom::spin::BVH<2>` provides the `locate_mortar` + primitive for). + +--- + +### §P4.8.19 Broad-phase candidate pairs via BVH (Batch 4.4-B) + +This batch implements the broad-phase spatial-search step of the +non-conforming face-mortar work. Given the nonmortar-side and +mortar-side face element lists for one periodic face pair, it +returns a CSR-format list of candidate `(s_idx, m_idx)` pairs +whose 2D-projected AABBs overlap. **No clipping yet** — the +fine-phase polygon clipping is Batch 4.4-C. + +**What Batch 4.4-B landed:** + + * `face_mortar_match_3d.{hpp,cpp}` (new) — public functions + `MatchClippedQuadFacePairs` and `MatchClippedTriFacePairs`, + sharing a templated implementation. Uses + `axom::spin::BVH<2>` keyed on mortar-element 2D AABBs. The + output type `ClippedPairCandidates` is CSR-format + `std::vector` for offsets / counts / + candidates, mirroring Axom's `BVH::findBoundingBoxes` + convention exactly. + * `test_face_mortar_match_3d.cpp` (new) — synthetic-input + unit test covering: (1) empty inputs, (2) trivial conforming + 4×4 vs 4×4 quad case, (3) non-conforming 4×4 vs 5×5 quad + case, (4) trivial conforming tri 4×4 case, (5) documented + perpendicular-axis-mismatch placeholder. Test does CSR + structural checks (offsets/counts consistency, + candidates.size() matches offsets.back()) which run cleanly + against the sandbox stub; the numerical candidate-count + assertions are info-only against the stub (which returns + empty BVH output) but become real checks against installed + Axom. + +**Implementation choices:** + + 1. **2D-projection convention.** Drop the perpendicular axis; + the two remaining axes are taken in cyclic order to + preserve right-handedness: + * `n="x"` → 2D = (y, z), indices (1, 2) + * `n="y"` → 2D = (z, x), indices (2, 0) + * `n="z"` → 2D = (x, y), indices (0, 1) + This matches the convention CCW vertex ordering on the + nonmortar face stays CCW in 2D. + 2. **Mortar AABB padding.** Mortar AABBs are expanded by + `aabb_pad_rel * max_mortar_edge_length` (default + `1e-9 * max_edge`), matching the architecture doc §3.6 + vertex-matching tolerance. Nonmortar query AABBs are NOT + padded — the mortar pad already covers slop, and double- + padding would over-count candidates. + 3. **CSR output not packed pair list.** Mirror's Axom's BVH + output shape directly. Downstream code (Batch 4.4-C) iterates + `for s in [0, n_nonmortar): for k in [offsets[s], offsets[s] + + counts[s]): m = candidates[k]`. + 4. **Templated impl.** `MatchClippedFacePairsImpl` + handles both quad and tri. The element struct provides + `coords`, `NumNodes()`, and `perpendicular_axis` — the + templated function uses only these. This lets us avoid + code duplication between the quad and tri public + overloads. + 5. **No code in `face_mortar_assembler_3d.{hpp,cpp}` changed.** + This file is the architectural seam (per §P4.4.6.10): + non-conforming work is contained in the new + `face_mortar_match_3d` module + (forthcoming) + `AssemblePairClipped` methods. The conforming code path is + untouched. + +**Axom API gotchas discovered during integration testing**: + + 1. **`findBoundingBoxes` requires PRE-ALLOCATED offsets and + counts.** The signature is + `findBoundingBoxes(ArrayView offsets, + ArrayView counts, + Array& candidates, + IndexType n_query, BBox* queries)`. + The `offsets` and `counts` are `ArrayView` (not `Array&`) + specifically because the caller controls their allocation — + they must be sized to `n_query` BEFORE the call. If you pass + unallocated arrays, Axom fires SLIC errors: + `[ERROR]: offsets length not equal to numObjs` + `[ERROR]: counts length not equal to numObjs` + Only `candidates` is allocated by Axom. + 2. **`offsets` has size `n_query`, NOT `n_query + 1`.** Axom + uses no sentinel. To get the total candidate count, use + `candidates.size()` directly. Our internal CSR convention adds + a sentinel `offsets[n_nonmortar] = candidates.size()` because + SciPy-style `[offsets[s], offsets[s+1])` iteration is more + natural for Batches 4.4-C/D, but that's our wrapper, not + Axom's. + 3. **Axom requires SLIC initialization for clean output.** + Without an active `axom::slic::SimpleLogger` (or equivalent), + Axom auto-initializes a fallback logger and prints a warning. + Tests that exercise Axom should construct + `axom::slic::SimpleLogger slic_logger;` at the top of `main()` + — RAII handles init / finalize. + 4. **Including `axom/core.hpp`, not `axom/axom.hpp`.** The + umbrella header for Axom Core is `axom/core.hpp`. There is + no top-level `axom/axom.hpp`. The other umbrella headers we + use are `axom/primal.hpp`, `axom/spin.hpp`, `axom/slic.hpp`. + 5. **CMake dep list needs the component targets, not just + `axom`.** The right form is + `list(APPEND ... axom axom::core axom::slam axom::slic)`. + `axom::primal` and `axom::spin` are header-only so they don't + need explicit listing, but `axom::slam` is a transitive + dep of `axom::spin::BVH`'s policy headers, and `axom::slic` + is needed at link time for the SLIC error reporting. + +**Validation status:** + + * Sandbox: 29/29 .cpp files syntax-clean, + `face_mortar_match_3d.cpp` and `test_face_mortar_match_3d.cpp` + additionally `-Wall -Wextra -Wpedantic` clean. + * Real Axom v0.14.0 on Mac: pending the user's next test run. + The test now does real numerical assertions (not just info + prints): + - 4×4 vs 4×4 quad conforming: each nonmortar gets ≥ 1 and + ≤ 9 candidates (self + up to 8 edge/corner neighbors via + the AABB pad); total in [16, 100]. + - 4×4 vs 5×5 quad non-conforming: each nonmortar gets ≥ 1; + total in [16, 200]. + - 4×4 vs 4×4 tri conforming: each nonmortar gets ≥ 2 (twin + + diagonal partner); total in [64, 600]. + If any assertion trips, the broad-phase output is being + read incorrectly — fix before proceeding to Batch 4.4-C. + +**Cross-references:** + + * Phase 4 plan §P4.4.6.10 — the full Phase 4.4 plan. + * Phase 4 plan §P4.8.18 — Axom build integration (prereq). + * Architecture doc §3.5–3.7 — geometric matching. + * Architecture doc §11.6 — face-mortar pseudocode. + +--- + +### §P4.8.20 Polygon clipping + fan-triangulation (Batch 4.4-C) + +This batch implements the fine-phase geometric step: take the +candidate `(s_idx, m_idx)` pairs from Batch 4.4-B and produce, for +each, the actual 2D-projected overlap polygon, then fan-triangulate +into a list of `ClippedSubTriangle` records keyed by nonmortar +index. Used by Batch 4.4-D's per-sub-triangle Dunavant quadrature. + +**What Batch 4.4-C landed:** + + * `face_mortar_match_3d.{hpp,cpp}` — added two structs + (`ClippedSubTriangle`, `ClippedSubTriangulation`) and two + public functions (`ClipQuadFacePairs`, `ClipTriFacePairs`) + sharing a templated implementation `ClipFacePairsImpl`. + Uses `axom::primal::clip(Polygon<2>, Polygon<2>)` for the + convex-on-convex Sutherland-Hodgman intersection. + * `test_face_mortar_match_3d.cpp` — added 4 new test cases: + (5) empty inputs, (6) quad conforming 4×4 (each nonmortar → + exactly 2 sub-tris, total area = 1.0 to 1e-12), (7) quad + non-conforming 4×4 vs 5×5 (≥ 1 per nonmortar, total area = 1.0 + to 1e-12), (8) tri conforming 4×4 (≥ 1 per nonmortar, total + area = 1.0 to 1e-12). + +**Tile-cover invariant** is the central correctness check: the +sum of all sub-triangle areas across one ClipFacePairs call equals +the nonmortar face's total 2D-projected area to 1e-12 relative. +This catches: + * Missing intersections (broad-phase under-coverage). + * Double-counting (same overlap region split across multiple + candidate pairs). + * Sign errors in the orientation-preserving 2D projection. + * Bugs in fan triangulation (off-by-one indexing, etc.). + +**Implementation choices:** + + 1. **CCW orientation is enforced INSIDE `BuildPolygon2D`, not assumed + from the upstream face-element convention.** This was a bug in the + first attempt: face elements are stored "CCW from their own outward + normal" in 3D, but the nonmortar and mortar faces have OPPOSITE + outward normals (they're on opposite sides of the periodic + interface). After 2D-projecting both into the same (a, b) plane, + one comes out CCW and the other CW — Sutherland-Hodgman silently + returns empty in that case. The fix: every polygon goes through a + shoelace signed-area check inside `BuildPolygon2D`, and CW polygons + are reversed via `axom::primal::Polygon::reverseOrientation()` + (added in Axom v0.10). This makes the matcher orientation-robust + w.r.t. any source convention. The fan-triangulation step asserts + `sa > 0` as a safety net. + 2. **Sliver filter via relative area tolerance.** Sub-triangles + whose `|signed_area| < area_tol_rel * nonmortar_2D_area` + are dropped. Default `area_tol_rel = 1e-12` — matches the + patch-test acceptance tolerance from the architecture doc. + This handles the AABB-pad over-counting from Batch 4.4-B: + shared-edge mortar candidates produce zero-area clip + polygons that get filtered here; no impact on assembled D + or A_m matrices. + 3. **Subject = nonmortar.** `clip(s_poly, m_poly)` is called + with nonmortar as the subject, mortar as the clipper. + For convex-on-convex the result *set* is the same either + way, but this convention reads as "restrict the nonmortar + region to the part inside the mortar" which matches the + mortar method's mathematical setup (the integral domain is + a sub-region of Γ⁻). + 4. **Output format: CSR by nonmortar index.** Same format as + `ClippedPairCandidates` for symmetry. Batch 4.4-D's + assembler iterates `for s in [0, n_nonmortar): for k in + [offsets[s], offsets[s+1]): tri = sub_tris[k]`. The + `m_idx` is embedded in each `ClippedSubTriangle` because + a single nonmortar may have sub-tris from multiple mortar + partners. + 5. **2D coords stored, perpendicular axis recovered at use + site.** Sub-tri vertices are stored in (a, b) physical + coords. The 3D point on the periodic face is recovered + downstream by re-inserting the constant perpendicular-axis + coordinate from the parent face element. This avoids + storing redundant data per sub-tri (the perpendicular coord + is identical for all sub-tris on one face). + 6. **Templated impl shared between quad and tri.** The + `BuildPolygon2D` helper uses `ElementT::NumNodes()` + and `coords` — works identically for quad (4 nodes) and tri + (3 nodes). The clipping algorithm doesn't care about input + vertex count for convex polygons. + +**Axom API gotcha discovered during integration testing**: + + * **`axom::primal::clip` is Sutherland-Hodgman; both inputs MUST + be CCW or it returns empty silently.** No warning, no assertion + fires — the result is just an empty polygon. This is + Sutherland-Hodgman's standard inside-half-plane semantics: + CW inputs invert the test, so every vertex appears "outside" + and gets rejected. Our `BuildPolygon2D` enforces CCW per + polygon, independent of source convention. + +**Validation status:** + + * Sandbox: 29/29 .cpp files syntax-clean. `face_mortar_match_3d.cpp` + and `test_face_mortar_match_3d.cpp` clean under + `-Wall -Wextra -Wpedantic`. + * Real Axom v0.14.0 on Mac: pending. Expected results on first + run: + - Test 6 (quad conforming 4×4): 32 sub-tris total, total + area = 1.0 to 1e-12, each sub-tri area exactly 0.03125. + - Test 7 (quad non-conforming 4×4 vs 5×5): variable count + (clipping subdivides), total area = 1.0 to 1e-12. + - Test 8 (tri conforming 4×4): 32 sub-tris total (one per + twin pair), total area = 1.0 to 1e-12. + If the tile-cover invariant trips, the most likely causes are: + (a) AABB pad too small to capture a true overlap (broad-phase + under-coverage), (b) clip filter `area_tol_rel` too aggressive, + (c) orientation flip in the 2D projection. + +**Cross-references:** + + * Phase 4 plan §P4.4.6.10 — the full Phase 4.4 plan. + * Phase 4 plan §P4.8.19 — Batch 4.4-B (broad-phase, prereq). + * Architecture doc §3.7 — Sutherland-Hodgman pseudocode (which + `axom::primal::clip` implements). + * Architecture doc §11.6 — face-mortar pseudocode (showing + where the clipped sub-triangulation feeds into the assembler). + +--- + +### §P4.8.21 Inverse iso-maps + 6-point Dunavant (Batch 4.4-D-1) + +This batch is the foundation for the clipped-pair assembler +(Batches 4.4-D-2 and 4.4-D-3). It provides three pure-utility +helpers that the `AssemblePairClipped` methods will call once per +sub-triangle quadrature point: + + * `InverseMapQuad2DAxisAligned(elem, a_idx, b_idx, a, b) → (xi, eta)` + — closed-form Q1 inverse for axis-aligned quad faces. Uses the + dual-basis representation `xi = -1 + 2 * (q · e_xi) / |e_xi|^2` + where `q` is the displacement from vertex 0 and `e_xi`, `e_eta` + are the edge vectors v0→v1 and v0→v3. For axis-aligned quads + the edge vectors are orthogonal in (a, b) so the dual basis is + just the inverse-length-squared scaling — no matrix solve + needed. No Newton iteration. Two MFEM_ASSERTs guard against + degenerate edges. + * `InverseMapTri2D(elem, a_idx, b_idx, a, b) → (lam_0, lam_1, lam_2)` + — closed-form P1 inverse via Cramer's rule on the 2×2 affine + system. Always exact for non-degenerate tris. `MFEM_ASSERT` + guards against zero 2D area. + * `DunavantTri6Pt()` — 6-point degree-4 Dunavant rule on the + reference simplex (|T| = 1/2). Required for clipped quad-face + sub-triangles where the bilinear-basis × bilinear-basis product + is degree 4 in barycentric. Tri-face clipped sub-tris stay at + `GaussTri3Pt` (degree 2 suffices). + +**Files added:** + + * `face_mortar_inverse_map_3d.{hpp,cpp}` — both inverse-map + helpers in their own translation unit (no Axom dep). Added to + `MORTAR_PBC_HEADERS` / `_SOURCES` unconditionally so they're + available even when `ENABLE_AXOM=OFF`. + * `test_face_mortar_inverse_map_3d.cpp` — round-trip tests for + both inverse maps (forward iso-map at canonical reference + points, then inverse, assert recovery to 1e-14) plus monomial- + integration tests for `DunavantTri6Pt` covering all monomials + `lam_0^p lam_1^q lam_2^r` with `p+q+r ∈ {0..4}` (15 monomials) + against the closed-form integral + `p! q! r! / (p+q+r+2)!`. + * `face_mortar_assembler_3d.{hpp,cpp}` — extended with + `QuadratureTri6Pt` struct + `DunavantTri6Pt()` implementation. + +**Why these are in two different files:** + +The inverse-iso-map helpers don't reference any Axom types, so they +live in their own module that compiles regardless of `ENABLE_AXOM`. +The 6-point Dunavant rule lives next to `GaussTri3Pt` / +`GaussQuad3x3` in the existing assembler module — it's a pure +quadrature utility and Axom-free. Only the per-sub-triangle +*walker* (Batch 4.4-D-2/3) is Axom-gated. + +**Validation status:** + + * Sandbox: 31/31 .cpp files syntax-clean (added 2 files this + batch). New code `-Wall -Wextra -Wpedantic` clean. + * Python regression 6/6 green. + * Real Axom: pending. Test runs *without* Axom — only requires + a normal mortar_pbc build. The 4 test cases: + 1. Quad inverse round-trip: 11 reference points (vertices, + mid-edges, center, 2 generic), each round-trips to 1e-14. + 2. Tri inverse round-trip: 8 barycentric points (vertices, + mid-edges, centroid, 1 generic), each round-trips to 1e-14. + 3. Dunavant 6-point weights sum to |T| = 1/2 to 1e-14. + 4. Dunavant 6-point integrates 15 monomials of degree ≤ 4 + exactly (to 1e-13). + +**Cross-references:** + + * Phase 4 plan §P4.4.6.10 design decision 4 — quadrature order + policy (3-point Dunavant for tri, 6-point for clipped quad + sub-tris). + * Phase 4 plan §P4.4.6.10 — the inverse-map closed-form is + spelled out in the "Algorithmic invariants" subsection. + * Architecture doc §11.6 — `locate_mortar` interface that these + helpers provide for the axis-aligned case. + * Reference: Dunavant 1985, "High degree efficient symmetrical + Gaussian quadrature rules for the triangle." Int. J. Numer. + Methods Eng. 21, 1129-1148. + +--- + +### §P4.8.22 Quad-quad clipped face mortar assembler (Batch 4.4-D-2) + +This batch is the algorithmic core of Phase 4.4 for Q1 quad face +elements. `AssembleQuadFacePairClipped` consumes the clipped +sub-triangulation from Batch 4.4-C and produces a `FaceMortarPairBlock` +matching the conforming-path interface bit-for-bit on conforming +inputs (the central correctness check) and correctly populated for +non-conforming inputs. + +**Files added:** + + * `face_mortar_assembler_clipped_3d.{hpp,cpp}` — Axom-gated. + Free function `AssembleQuadFacePairClipped` (not a class + method) so the conforming `QuadFaceMortarAssembler` class + header stays Axom-free. Replicates four small helpers + (`AxisIndex`, `DiscoverKeptGtdofs`, `BoundaryTagToSides`, an + axis-aligned-only `NonmortarJacobianAxisAligned`) in its own + anonymous namespace. The duplication is deliberate: the + conforming class encapsulates these as private helpers and + we don't want to widen its API just to share them with the + clipped assembler. + * `test_face_mortar_assembler_clipped_3d.cpp` — the central + correctness gate. Routes 4×4 vs 4×4 conforming meshes through + BOTH the conforming and clipped paths, then asserts entry-by- + entry agreement on `D` (exact, both paths use the same 9-pt + rule) and `A_m` (1e-12 relative, FP-rearrangement only). + +**The dual-loop structure (the central principle):** + +The clipped assembler implements the D-vs-A_m domain split +documented in arch §3.5 and §P4.4.6.10. For each nonmortar +element s: + + * **Pass 1 (D)**: 9-point Gauss-Legendre rule on the parent + reference quad, accumulating + `D_loc[k] += phys_w * N_nonmortar[k]`. + This is the *full* element integration. Wohlmuth biorthogonality + lumps D to its diagonal once summed over all 9 q-pts. + Reused verbatim from the conforming assembler. + * **Pass 2 (A_m)**: walk all sub-triangles owned by s. For each + sub-tri, Dunavant 6-point rule on the sub-tri reference, + computing barycentric → 2D physical (a, b) → inverse-iso-map + to nonmortar `(xi_nm, eta_nm)` AND mortar `(xi_m, eta_m)` → + evaluate `M_dual` and `N_mortar` → accumulate + `A_loc[k][l] += sub_phys_w * M_dual[k] * N_mortar[l]`. + +The two passes are independent — D doesn't see sub-triangles, A_m +doesn't see the parent reference quad. This matches the 2D +prototype's structure and keeps Wohlmuth biorthogonality intact +(holds when D is integrated over the full element, not segment- +wise). + +**Why no mortar-side permutation:** + +The conforming assembler uses `MortarRefFromPermutation` and +`ReorderMortarShape` to handle the case where the mortar element's +local node ordering differs from the nonmortar's. In the clipped +path, the inverse-iso-map gives mortar `(xi_m, eta_m)` directly +in the mortar's own reference frame, so we evaluate `NQuad4` on +the mortar's own coords and pair `N_mortar[l_loc]` with +`m.gtdofs[l_loc]` directly. No permutation needed, no +reordering — simpler than the conforming code. + +**Sub-triangle Jacobian:** + +`DunavantTri6Pt` weights sum to `|T_ref| = 1/2`. For a +sub-triangle of physical 2D area `A`: + `∫_{phys} f dA ≈ Σ w_q · f(λ_q) · 2A` +i.e., `J_sub = 2 * sub_tri.area`. Sum check: `(1/2) * 2A = A`. ✓ +Mirrors the conforming tri assembler's `J_nonmortar = 2 * +phys_tri_area` convention. + +**Validation status:** + + * Sandbox: 33/33 .cpp files syntax-clean. New code + `-Wall -Wextra -Wpedantic` clean. + * Python regression 6/6 green. + * Real Axom: pending. Two test cases: + 1. 4×4 vs 4×4 conforming agreement: D entries match exactly + (1e-14), A_m entries match to 1e-12 relative. + 2. Σ D entries equals nonmortar face area (1.0) to 1e-12 — + a coarse independence check. + + The conforming-via-clipped agreement test is the actual + correctness gate. If it passes, the assembler is correct on + conforming inputs, which means: + - Per-element D accumulation is correct. + - Sub-triangle Jacobian is correct. + - Inverse-iso-maps for both nonmortar and mortar are correct. + - Sentinel-aware scatter is correct. + - Wohlmuth dispatch via `boundary_tag` is correct. + The non-conforming case differs only in which sub-triangles are + produced by `ClipQuadFacePairs` — which Batch 4.4-C already + validated via the tile-cover invariant. So passing this gate + gives us high confidence in the full pipeline. + +**Cross-references:** + + * Phase 4 plan §P4.4.6.10 — full Phase 4.4 plan. + * Phase 4 plan §P4.8.20 — Batch 4.4-C clipping geometry (prereq). + * Phase 4 plan §P4.8.21 — Batch 4.4-D-1 helpers (prereq). + * Architecture doc §3.5 — D-vs-A_m domain split. + * Architecture doc §11.6 — face-mortar assembly pseudocode. + +--- + +### §P4.8.23 Tri-tri clipped face mortar assembler (Batch 4.4-D-3) + +This batch completes the Phase 4.4 assembler for P1 tri face elements. +`AssembleTriFacePairClipped` mirrors `AssembleQuadFacePairClipped` +structurally with three element-type-specific differences: + + 1. **Quadrature on clipped sub-tris is `GaussTri3Pt` (degree 2)**, not + `DunavantTri6Pt` (degree 4). The bumped-up rule was needed for Q1 + because Q1·Q1 = degree 4 in barycentric; for P1, P1·P1 = degree 2, + and 3-point Dunavant integrates that exactly. Same rule used by the + conforming tri assembler — no quadrature-rule mismatch between paths + for tri faces. + 2. **D-side Jacobian: `J = 2 * |T_phys|`** via 3D cross-product + magnitude (`TriFullJacobian` helper). No axis-alignment shortcut — + tri faces are generally oblique (the hypotenuse isn't axis-aligned), + so we use the same 3D-cross-product Jacobian as the conforming tri + path. + 3. **Inverse-iso-map: `InverseMapTri2D` (Cramer's rule)** returns + barycentrics directly. Both nonmortar and mortar tri parents use + this map. + +**What landed:** + + * `face_mortar_assembler_clipped_3d.{hpp,cpp}` extended with: + - `BoundaryTagToDropsTri` helper (anonymous namespace, mirroring + the conforming class's private method). + - `TriFullJacobian` helper. + - Public `AssembleTriFacePairClipped` function. + * `test_face_mortar_assembler_clipped_3d.cpp` extended with: + - `MakeTriGridWithGtdofs` helper (4×4 conforming tri grid: 32 tris, + 25 unique gtdofs, sequential numbering). + - `test_tri_conforming_agreement_4x4`: routes 4×4 vs 4×4 conforming + tri meshes through both paths, asserts entry-by-entry agreement + on D (1e-14) and A_m (1e-12 relative). + - `test_clipped_tri_d_total_area`: independent Σ D = face area + check. + +**Why no mortar-side permutation (same as Batch 4.4-D-2):** + +The conforming tri assembler uses `MortarBaryFromPermutation` and +`ReorderMortarShape` to handle local-node ordering mismatches. In the +clipped path, the inverse-iso-map gives mortar barycentrics directly +in the mortar's own local frame, so `NTri3(lam_m)` is naturally aligned +with `m.gtdofs[l_loc]`. Cleaner inner loop, no permutation indirection. + +**Validation status:** + + * Sandbox: 33/33 .cpp files syntax-clean. New code + `-Wall -Wextra -Wpedantic` clean. + * Python regression 6/6 green. + * Real Axom: pending. Combined test now exercises all four cases: + quad agreement (Test 1), quad Σ D (Test 2), tri agreement (Test 3), + tri Σ D (Test 4). Expected output: + D max-error = 0 (or ε) max |D| ≈ 0.0625 + A_m max-error = O(1e-15) max |A_m| ≈ 0.0625 + Σ D = 1.0 (expected 1.0) (both element types) + +**Cross-references:** + + * Phase 4 plan §P4.4.6.10 — full Phase 4.4 plan. + * Phase 4 plan §P4.8.22 — Batch 4.4-D-2 (sibling, quad version). + * Architecture doc §3.5 — D-vs-A_m domain split. + +--- + +### §P4.8.24 Discrete reproduction tests (Batch 4.4-D-4) + +This batch validates the assembled `(D, A^m)` block as a mortar +**projector** on genuinely non-conforming meshes. Without a reference +assembler to compare against (the conforming-via-clipped agreement +test only works when meshes happen to coincide), correctness on +non-conforming inputs has to be checked physically — by verifying +that the projector reproduces functions in the test space exactly. + +**The two reproduction properties:** + +For the mortar projector `P u_+ = D⁻¹ A^m u_+`: + + * **Constant reproduction**: `P · 1 = 1`. Equivalent to row-sum + biorthogonality `A^m 1 = D 1`, which is the construction + principle of the Wohlmuth dual basis. If non-conforming clipping + has missed any sub-region or double-counted any overlap, this + fails immediately because `(A^m 1)[k] = ∫ M_k · 1 dA` summed over + sub-regions no longer equals `D[k] = ∫_E N_k dA` over the full + nonmortar element. + * **Linear reproduction**: `P u(x) = u(x)` for any linear field + `u(x) = α·x_a + β·x_b + γ` in the (a, b) plane. This is the + discrete completeness property of the mortar method on flat + axis-aligned interfaces — the property that motivates using the + dual basis in the first place. If any inverse-iso-map is wrong, + or any sub-triangle Jacobian is mis-scaled, linear reproduction + fails because `(A^m u)[k]` no longer equals `u(x^k) · D[k]`. + +Both checks are independent of any reference assembler. Passing them +on a 4×4 vs 5×5 setup demonstrates correctness end-to-end. + +**Files changed:** + + * `test_face_mortar_assembler_clipped_3d.cpp` extended with: + - `ApplyMortarProjector(block, u_plus) → u_minus` helper that + computes `D⁻¹ A^m u_+` via direct CSR walk and per-row + inverse-D scaling. Asserts strict positivity of D entries + (lumped-positivity guard). Pure host-side linear algebra. + - `GtdofToVertexPos` / `GtdofToVertexPosTri` helpers that + reconstruct `(x, z)` coordinates from a gtdof given the + grid's known sequential numbering convention. The grid + builders (`MakeQuadGridWithGtdofs`, + `MakeTriGridWithGtdofs`) use vertex `(i, j) → base + i + + j*(n+1)`, so the inverse is `(local % (n+1), local / (n+1))`. + - 6 new test cases: + 5. Constant reproduction, quad conforming 4×4. + 6. Constant reproduction, quad NON-conforming 4×4 vs 5×5. + 7. Linear reproduction, quad conforming 4×4 (3 fields). + 8. Linear reproduction, quad NON-conforming 4×4 vs 5×5 + (3 fields). + 9. Linear reproduction, tri conforming 4×4 (3 fields). + 10. Linear reproduction, tri NON-conforming 4×4 vs 5×5 + (3 fields). + +**The three linear fields tested:** + * `u(x, z) = x` — pure parametric x dependence. + * `u(x, z) = z` — pure parametric z dependence. + * `u(x, z) = 1.7·x + 2.3·z + 0.5` — generic linear. +The first two catch axis-swap bugs (where the projector confuses +the two in-plane axes). The third catches scaling and offset +errors. + +**Validation status:** + + * Sandbox: 33/33 .cpp files syntax-clean. New code clean. + * Python regression 6/6 green. + * Real Axom: pending. Expected per-field max-error around + 1e-14 to 1e-13 across all 6 test cases (tighter on conforming, + slightly looser on non-conforming due to clipping rearrangement + in the A^m sums). If any case shows max-error > 1e-12, it's + a real bug — the most likely diagnostic order: + 1. **Constant reproduction fails** → biorthogonality identity + is broken. Most likely cause: clipping missed a sub-region + (Σ D = face area would also fail in 4.4-D-2/3 — but that + passed, so this is unlikely). + 2. **Linear reproduction fails on `u = x`** but constant + passes → inverse-iso-map for the x axis is wrong. Check + `InverseMapQuad2DAxisAligned` axis ordering. + 3. **Linear reproduction fails on `u = z`** symmetrically. + 4. **Generic linear fails but axis-only cases pass** → likely + a subtle interaction between Wohlmuth modifications and the + linear field (shouldn't happen since `boundary_tag = "none"` + throughout this test). + +**This is the Phase 4.4 numerical correctness gate.** If all 6 +reproduction tests pass on Mac, the full clipped pipeline is +end-to-end correct on non-conforming meshes, and we can proceed +to Batch 4.4-E (dispatch integration into `BuildLocalPairBlocks` +and the production patch-test driver). + +**Cross-references:** + + * Phase 4 plan §P4.4.6.10 — full Phase 4.4 plan, design + decisions 5–6. + * Phase 4 plan §P4.8.22 — Batch 4.4-D-2 (quad assembler). + * Phase 4 plan §P4.8.23 — Batch 4.4-D-3 (tri assembler). + * Wohlmuth 2000, "A mortar finite element method using dual + spaces for the Lagrange multiplier." SIAM J. Numer. Anal. + 38(3), 989-1012 — derivation of the dual basis from the + biorthogonality + linear-completeness requirements. + +--- + +### §P4.8.25 Conforming-vs-clipped dispatch (Batch 4.4-E Part 1) + +This batch wires the clipped-path machinery (Batches 4.4-A through +4.4-D-4) into the production `BoundaryClassifier3D::BuildLocalPairBlocks` +flow. After this batch, `BuildLocalPairBlocks` automatically detects +non-matching meshes and routes them to the clipped assembler — no +caller changes required. + +**The dispatch logic:** + +For each (axis, mortar/nonmortar, geometry_kind) bucket: + + 1. Call `TryMatchConformingFacePairs` (new try-style API). + 2. If it returns `optional>` with a value → meshes are + conforming → call `AssemblePairConforming` (existing fast path). + 3. If it returns `nullopt` → meshes are non-matching: + - **`MORTAR_PBC_HAS_AXOM` defined**: call `MatchClippedFacePairs` + + `ClipFacePairs` + `AssembleQuad/TriFacePairClipped` + (clipped fallback). + - **Not defined**: `MFEM_ABORT` with a clear message instructing + the user to rebuild with `ENABLE_AXOM=ON`. + +**Files added/changed:** + + * `face_mortar_assembler_3d.{hpp,cpp}` — added try-style overloads: + - `TryMatchConformingFacePairs(quad)` returning + `std::optional>`. + - `TryMatchConformingFacePairs(tri)` returning + `std::optional>`. + - Both share the algorithm of `MatchConformingFacePairs` but + return `std::nullopt` on non-1:1 candidate count instead of + aborting. The original `MatchConformingFacePairs` overloads + remain unchanged — existing tests that rely on the abort-on- + mismatch semantics keep working. + * `boundary_classifier_3d.cpp` — `BuildLocalPairBlocks` rewired + to use the try-style API + Axom-gated fallback. Conforming + fast path unchanged; clipped path used silently when meshes + don't match. + * `CMakeLists.txt` — when `ENABLE_AXOM=ON`, the build sets + `target_compile_definitions(mortar_pbc_lib PUBLIC MORTAR_PBC_HAS_AXOM)`. + This makes the dispatch fallback compile-in only when Axom is + available; without Axom, the dispatch's clipped branch + compiles to a clean `MFEM_ABORT` with an actionable message. + +**Why preprocessor-gating instead of always-compiled:** + +The clipped-path machinery (`face_mortar_match_3d.{hpp,cpp}` and +`face_mortar_assembler_clipped_3d.{hpp,cpp}`) is in the library only +when `ENABLE_AXOM=ON`. If `BuildLocalPairBlocks` always compiled the +clipped fallback, builds with `ENABLE_AXOM=OFF` would fail to link +(no `AssembleQuadFacePairClipped` available). The `#ifdef +MORTAR_PBC_HAS_AXOM` guard keeps the conforming-only build path +self-contained: no Axom dependency, no clipped fallback, clean +abort with explanatory message if a non-conforming mesh ever shows +up. + +**Validation status:** + + * Sandbox: 33/33 .cpp files clean WITHOUT `MORTAR_PBC_HAS_AXOM` + (production build), AND 33/33 clean WITH `MORTAR_PBC_HAS_AXOM` + (Axom-enabled build). 66/66 total across both configurations. + * Python regression 6/6 green (Python prototypes don't exercise + this dispatch — they're algorithm references, not production). + * Real Axom: pending. The dispatch's correctness on conforming + meshes is implicit — every existing patch test still uses + conforming meshes, and they should pass unchanged because the + try-style API returns `Some` and the conforming branch fires + exactly as before. Validation that the clipped branch fires on + actual non-conforming meshes requires Batch 4.4-E Part 2 + (production-shape patch test driver). + +**What's still missing (Batch 4.4-E Part 2):** + + * A `test_patch_3d_pbc_nonconforming.cpp` executable that builds + a non-matching MFEM mesh and runs the full FE elasticity solve + end-to-end. Construction of a non-matching periodic mesh in MFEM + is non-trivial (`MakeCartesian3D` produces conforming meshes; + we'd need a custom mesh constructor or the + `Mesh(int Dim, int NVert, int NElem)` low-level API). Deferred + to a follow-up turn — the algorithmic correctness is already + validated by Batch 4.4-D-4's reproduction tests on synthetic + non-conforming face element lists. + +**Cross-references:** + + * Phase 4 plan §P4.4.6.10 — full Phase 4.4 plan, design + decision 5 ("Conforming fast path is preserved"). + * Phase 4 plan §P4.8.18 — Batch 4.4-A Axom build integration. + * Phase 4 plan §P4.8.24 — Batch 4.4-D-4 reproduction tests + (algorithmic prereq). + +--- + +### §P4.8.26 Production-shape non-conforming patch test (Batch 4.4-E Part 2) + +This batch closes Phase 4.4 by adding a production-shape end-to-end +patch test that exercises the entire clipped-path pipeline through +a real FE elasticity solve. Rather than constructing a non-matching +MFEM mesh from scratch (which would require the low-level mesh API +or anisotropic h-refinement with hanging nodes — out of Phase 4.4 +scope), we apply an **in-plane node perturbation** to one periodic +face of a standard `MakeCartesian3D` mesh. + +**The perturbation strategy:** + +For each node at `(x, y, z)` with `y == L`: + `x_new = x + amplitude · sin(π · x / L)` + (y, z unchanged) + +This satisfies all clipped-path contract requirements: + * **Corners stay exact** (sin vanishes at x=0 and x=L) — corner + Dirichlet BCs from `F·X` remain aligned with the FE solve. + * **Faces stay flat** (y = L preserved on the perturbed face; + other faces untouched) — axis-aligned face-element assumption + in `InverseMapQuad2DAxisAligned` and `NonmortarJacobianAxisAligned` + still holds. + * **No degenerate hexes** (max shift `amplitude = 0.05` against + cell width `0.25` on a 4³ mesh = 20% — well-conditioned). + * **Linear-field reproduction unaffected** — Q1 hexes reproduce + `u(x) = F·x` exactly regardless of element shape. + +The y-face periodic pair becomes non-matching (centroid distances +of order `0.05` vs the `1e-9` match tolerance), triggering +`TryMatchConformingFacePairs` → `nullopt` → +`BuildLocalPairBlocks` falls back to the clipped path. + +**Files added/changed:** + + * `patch_test_driver_3d.hpp` — added optional + `std::function mesh_perturbation` field to + `PatchTestConfig`. Default `nullptr` means "no perturbation" + (existing tests unchanged). Contract documented inline. + * `patch_test_driver_3d.cpp` — added single hook call between + `MakeCartesian3D + ApplyAttributePattern` and `ParMesh` ctor. + * `test_patch_3d_pbc_nonconforming.cpp` — new test executable + that constructs `cfg` with the y=L face perturbation and + delegates to `RunPatchTest3D`. CLI mirrors `test_patch_3d_pbc` + plus an `--amplitude` override (default 0.05). + * `CMakeLists.txt` — registered the new test (Axom-gated, since + the dispatch falls back to the clipped path which requires + Axom). + +**PASS criteria** are inherited from `RunPatchTest3D`: + * Krylov converged. + * `||du||_inf < 1e-7` (homogeneous-elastic exactness). + * `|| - F_macro||_inf < 1e-9` (homogenization check). + * `||C·u_total - C·u_lin||_inf < 1e-9` (constraint residual). + +**What this test exercises:** + + * `BoundaryClassifier3D` correctly identifies the y face pair + despite face node mismatches. + * `TryMatchConformingFacePairs` correctly returns `nullopt` + (verified by reaching the clipped fallback). + * `MatchClippedQuadFacePairs` (BVH broad-phase) on real FE + face-element data. + * `ClipQuadFacePairs` (Sutherland-Hodgman) on real face data. + * `AssembleQuadFacePairClipped` produces a `(D, A^m)` block + consumed unchanged by `MortarSaddlePointSystem`. + * `SaddlePointSolver` converges on the constrained system. + * Constraint residual `C·u_total = C·u_lin` after solve. + * Patch test residual `||du||_inf` at FE-solver tolerance. + +**Validation status:** + + * Sandbox: 34/34 .cpp files clean WITHOUT `MORTAR_PBC_HAS_AXOM`, + 34/34 clean WITH it (68/68 across both build configs). New + code `-Wall -Wextra -Wpedantic` clean. + * Python regression 6/6 green. + * Real Axom on Mac: pending. The expected behavior is that this + test passes with the SAME numbers as the conforming + `test_patch_3d_pbc` (Krylov converges, `||du||_inf` near + 1e-9, constraint residual near 1e-12). If the test fails: + 1. **Krylov diverges**: assembled `(D, A^m)` is wrong shape + or has unexpected zeros — most likely a sentinel bug in + the clipped-path scatter. Diagnostics: `nnz(A^m)` should + match the conforming case minus contributions on the + perturbed face (typical: similar order of magnitude). + 2. **Krylov converges but `||du||_inf > 1e-7`**: the + constraint is being applied but isn't reproducing linear + fields. Most likely cause: an inverse-iso-map or + sub-triangle Jacobian bug specific to this face's + non-uniform geometry. Diagnostic check: re-run the + reproduction tests from Batch 4.4-D-4 with similar + non-uniform face geometry to see if they still pass. + 3. **Constraint residual high but `du` is small**: the + constraint matrix is computing a different projection + than the solver expects. Most likely cause: row/col + ordering mismatch between `D`, `A^m`, and the `C` block + consumed by `MortarConstraintOperator`. Less likely + since the conforming dispatch test already validated + this — but worth checking. + + This is the production-shape gate for Phase 4.4. If it passes, + the entire Phase 4.4 stack (Batches 4.4-A through 4.4-E) is + end-to-end correct on a real FE problem and the phase is + complete. + +**Cross-references:** + + * Phase 4 plan §P4.8.25 — Batch 4.4-E Part 1 (dispatch + integration; this batch builds on it). + * Phase 4 plan §P4.8.24 — Batch 4.4-D-4 reproduction tests + (algorithmic prereq). + * Architecture doc §3.5 — D-vs-A_m domain split. + +--- + +## §P4.9 Mapping from Python files to C++ files + +This table is for reference when porting; each row is one focused +porting unit. + +| Python module | C++ files | Phase | +|--------------------------------------------|-------------------------------------|-------| +| `mortar_pbc/types_3d.py` | `types_3d.hpp` | 4.1.A | +| `mortar_pbc/mortar_3d.py` | `mortar_assembler_2d.{hpp,cpp}` | 4.1.A | +| | `face_mortar_assembler_3d.{hpp,cpp}`| 4.1.A | +| `mortar_pbc/face_mortar_3d.py` | (same as above) | 4.1.A | +| `mortar_pbc/mortar_2d.py` (edge-mortar use)| (subset of `mortar_assembler_2d`) | 4.1.A | +| `mortar_pbc/boundary_3d.py` | `boundary_classifier_3d.{hpp,cpp}` | 4.1.A | +| `mortar_pbc/constraint_builder_3d.py` | `constraint_builder_3d.{hpp,cpp}` | 4.1.A | +| `mortar_pbc/elastic_3d.py` | `elastic_3d_helpers.{hpp,cpp}` | 4.1.A | +| `mortar_pbc/saddle_point.py` | `saddle_point_solver.{hpp,cpp}` | 4.1.A | +| `mortar_pbc/visualization.py` | `visualization.{hpp,cpp}` | 4.1.A | +| `mortar_pbc/multistep_driver.py` | `mortar_pbc_driver.{hpp,cpp}` | 4.1.B | +| `examples/patch_test_3d_pbc.py` | `examples/patch_test_3d_pbc.cpp` | 4.1.A | +| `examples/patch_test_3d_heterogeneous.py` | `examples/patch_test_3d_heterogeneous.cpp` | 4.1.B | +| `examples/patch_test_3d_checkerboard.py` | `examples/patch_test_3d_checkerboard.cpp` | 4.1.C | +| `tests/test_*.py` (6 suites) | `tests/test_*.cpp` (6 suites) | 4.1.D | + +--- + +## §P4.10 Best-practices C++ checklist + +These are non-negotiable for the port to be acceptable. + +### Memory and resource management +- All owning pointers are `std::unique_ptr`. No raw `new`/`delete`. +- All borrowed pointers are references or `mfem::Operator&` / + `const mfem::Operator&`. +- All collective MPI operations are documented with + `// [collective]` comment AT the call site. +- `MFEM_VERIFY(cond, msg)` for invariants the user could violate; + `MFEM_ASSERT(cond, msg)` for invariants we control. + +### MPI discipline +- **Every rank in a given communicator reaches every collective on + that communicator.** No `if (rank == 0)` around AllReduce / + AllGather / Barrier. (Mortar §10.4.) +- The framework uses TWO communicators: **WORLD** (volume work) and + **boundary_comm** (boundary work; §P4.4.0). Document collective + context in every public method's docstring, naming the comm: + `[collective on WORLD]`, `[collective on boundary_comm]`, or + `[local]`. This is non-negotiable. +- All boundary-comm operations must be guarded with + `if (boundary_comm != MPI_COMM_NULL) { ... }` since interior ranks + receive `MPI_COMM_NULL` from `MPI_Comm_split`. +- Prefer `mfem::Vector` / `mfem::ParVector` over raw double*. + +### Avoid runtime polymorphism in hot loops +- Mortar element-type dispatch via templates, not virtual functions: + ```cpp + template // NV = 3 (tri) or 4 (quad) + class FaceMortarAssembler; + ``` +- Per-pair iteration in `MortarConstraintOperator::Mult` should be a + flat `for` loop over a packed `std::vector` with no + pointer chasing. + +### Const-correctness +- Methods that don't modify `*this` are `const`. +- Setup-time methods (in classifier, constraint builder) may be + non-const, but the resulting state is then immutable; expose only + const accessors after setup. + +### Error messages +- Match the Python prototype's level of detail. Failed `MFEM_VERIFY` + messages should explicitly name the invariant violated, not just + "assertion failed". Examples in mortar §11.7.2. + +### Caliper instrumentation +- One `CALI_CXX_MARK_SCOPE` per non-trivial method, named per §P4.6.4. +- No redundant nesting; if a method only calls one annotated child, + don't annotate the parent. + +### Dimension genericity +- `BoundaryClassifier2D` and `BoundaryClassifier3D` are separate + classes (mirror of Python). No template-on-dim. The 2D and 3D codes + diverge in non-trivial ways (mortar §5.4 wirebasket, §11.4 mixed + meshes); template-on-dim hides those differences awkwardly. +- Helpers like `apply_linear_part`, `compute_volume_averaged_F` ARE + dim-generic and use `pmesh.Dimension()` at runtime. + +--- + +## §P4.11 Decisions captured (for future-conversation context) + +These are the answers from the original questions plus the +follow-up refinements, captured explicitly so a fresh conversation +can read just this document and have full context: + +1. **GPU support**: ExaConstit builds with MFEM GPU support. Hypre+GPU + for vector-dim problems is currently broken upstream; targeting + CPU Hypre + GPU MFEM-K-action initially. The EA constraint path + (Phase 4.3) is the GPU-future-proofed component. + +2. **Hypre version**: 3.1. No compatibility constraints expected. + +3. **Directory placement**: Phase 4 lives in `tests/mortar_pbc/`. + After full validation (all of Phase 4 green), promote to + `src/mortar_pbc/`. Within `tests/`, code lives in a subdirectory + `mortar_pbc/` (i.e. `tests/mortar_pbc/`). + +4. **Validation drivers**: standalone executables, not extensions to + the existing `mechanics` executable. Each test mode (homogeneous, + heterogeneous, checkerboard) is its own .cpp file. + +5. **AllGather refactor**: AllGather-based matching in Phase 4.1. + Distributed-hash refactor is Phase 4.2, **the very next step** + after Phase 4.1 is green. Not deferred to Phase 5. + +6. **Boundary subcommunicator**: ALL setup-time boundary work runs + on a `boundary_comm` created via `MPI_Comm_split` at driver + startup; interior ranks (those with no local boundary elements) + are excluded entirely. Volume work (K, Krylov inner products, + volume-averaged F) stays on WORLD. C is constructed on WORLD + with empty row blocks for interior ranks. (§P4.4.0). This is in + from Round 1, not deferred — it's a separate, complementary + improvement to the Phase 4.2 distributed-pair matching refactor. + +7. **Krylov solver options**: Three Krylov solvers supported, with + MINRES as default (matches Python prototype). MINRES for + symmetric K, GMRES for non-symmetric K, BiCGStab as a constant- + memory non-symmetric alternative. CG explicitly rejected with + a clear error message (the system is indefinite). Selectable + via `--solver={minres,gmres,bicgstab}` flag in the validation + drivers. (§P4.4.7). + +8. **MPI_Comm storage**: the boundary_comm lives in ExaConstit's + existing `SimulationState` class, which already manages the few + non-WORLD communicators in the codebase. SimulationState owns + creation and destruction; classifier / constraint builder / + driver take it by reference. No separate RAII wrapper needed. + (§P4.8.7, Trap 3.) + +9. **Phase 4.2 pair-matching algorithm**: 2D regular tile + partitioning of the parametric plane (Strategy B in §P4.4.4), + chosen over hash-based partitioning (A) and bbox-direct lookup + (D). Tile partitioning preserves spatial locality so the post- + matching AllToAll for nonmortar-DOF-ownership stays small. Bbox- + based direct lookup is asymptotically cheaper but adds + significant complexity around irregular METIS partitions; held + in reserve as a follow-up optimization if profiling Strategy B + at p ≈ 30 shows it's a bottleneck. + +--- + +## §P4.12 Cross-references to architecture doc + +When porting, consult the architecture doc for the underlying derivations: + +- **Mortar dual basis**: §4.0–§4.7 (theory), §4.8–§4.12 (higher-order + considerations, deferred to Phase 6+). +- **Wohlmuth corner modifications**: §5.1–§5.6. +- **Wirebasket hierarchy**: §5.4 (the mortar/nonmortar assignment rule). +- **Saddle-point system**: §6.1–§6.7. +- **Warm-start mechanics**: §7.1–§7.6. +- **Volume-averaged F homogenization check**: §8.1–§8.4. +- **Reference frame discipline**: §9.1–§9.4 (the byNODES/byVDIM trap + is in §9.4 specifically). +- **Distributed-driver invariants**: §10.4. +- **MFEM API gotchas**: §10.5. +- **3D mesh classifier**: §11.7 (overall), §11.7.1 (snap-coord cross- + rank keys), §11.7.2 (runtime attribute discovery), §11.7.3 (what's + in C's nullspace). +- **Existing C++ class sketch**: §13.2. +- **Hooks into ExaConstit infrastructure**: §13.3 (the BCManager / + SystemDriver integration plan, deferred to Phase 5). +- **Upstream MFEM contribution path**: §13.5. + +--- + +## §P4.13 Done criteria for Phase 4 + +Phase 4 is **done** when ALL of these hold: + +- [ ] All three C++ validation drivers (homogeneous, heterogeneous, + checkerboard) pass at np=1, 4, 16, 256 hex+tet. +- [ ] Phase 4.1.A (homogeneous) bit-compares to Python at np=1 hex, + n=4 mesh: identical C, identical du, identical within + Krylov tolerance. +- [x] **Phase 4.2 distributed-pair matching is implemented** + (tile partitioning Strategy B, Batches G–N). Validated + at np=1 (unit tests + patch tests, numerically identical to + Phase 4.1) and np=7 (heterogeneous checkerboard patch test). + Pending validation at np=1024 — final scaling check before + §P4.13 marks this fully done. +- [x] **Phase 4.3 EA constraint path is implemented** + (`MortarConstraintOperator` + `MortarSaddlePointSystem` + adapter + saddle-point solver `Solve(K, C_op, ...)` overload, + Batches O–S). A/B validation against the HypreParMatrix path + runs in two layers: matvec-level at np=1 (Batch Q's + `test_mortar_constraint_operator`, tolerance 1e-12) and + end-to-end at np=1 (`test_patch_3d_pbc_ea_compare`, tolerance + 1e-7). Pending: end-to-end A/B at np=4 / np=7 to exercise the + Alltoallv import / export topology with real off-rank data. +- [~] **Phase 4.3.B GPU port — first pass complete** (Batch X). + Forward `Mult` ported to `mfem::forall` over flat arrays + built at construction by `BuildFlatRowArrays`; all Vector + accesses across the EA path, saddle-point solver, and patch + driver use typed memory-manager accessors + (`HostRead`/`HostWrite`/`HostReadWrite`). Patch tests run + cleanly under MFEM's `DEVICE_DEBUG` mode on host build. + Pending for Phase 4.3.B "fully done" (see §P4.4.6.9 for + details): + * atomic-add `MultTranspose` scatter on device, + * real CUDA / HIP build validation, + * `MPI_Allreduce`-based cross-rank A/B comparison once + atomic adds are in place, + * performance profiling and optimization. +- [ ] All five C++ unit-test suites pass. +- [ ] Caliper profiling shows expected hot-path distribution + (saddle-point solve dominates, not classifier setup or mortar + integration). +- [ ] No `// TODO` markers in production code paths (only in + validation drivers if at all). +- [ ] Doxygen-complete public API for all four core classes. +- [ ] `tests/mortar_pbc/CMakeLists.txt` builds standalone, links + against MFEM + MPI without modifying ExaConstit's main CMake. + +When done, code moves from `tests/mortar_pbc/` to `src/mortar_pbc/` +and Phase 5 (ExaConstit integration) begins. diff --git a/experimental/mortar_pbc_proto/docs/PHASE5_EXACONSTIT_INTEGRATION_v7.md b/experimental/mortar_pbc_proto/docs/PHASE5_EXACONSTIT_INTEGRATION_v7.md new file mode 100644 index 0000000..c1ffcb9 --- /dev/null +++ b/experimental/mortar_pbc_proto/docs/PHASE5_EXACONSTIT_INTEGRATION_v7.md @@ -0,0 +1,4429 @@ +# Phase 5 (v7) — ExaConstit Integration: Mortar PBC into Production Solver + +> **This document is v7 and supersedes v6 in its entirety.** v7 adds the +> Phase 5.11 implementation batch (saddle-system residual scaling) that +> shipped after v6 was written, plus two new hazard entries in §P5.14 +> for traps surfaced during that work. The substantive additions: +> +> 1. **New §P5.19 content section** documenting the saddle residual +> scaling stack end-to-end: the asymmetric block-diagonal scaling +> formulation, Rule-A unit-balance selection with floor/cap guards, +> the `FaceEdge` / `PerPair` sub-block partition choice, the three +> operator/solver/preconditioner wrappers, NR/NRLS/TRDOG integration, +> the per-Newton-iter diagnostic logger, the `InspectingIterativeSolver` +> post-solve telemetry hook, and the two-run-diff workflow for +> wrapper-transparency validation. +> 2. **Phase 5.11 entry added to §P5.13 phasing** with sub-batches A +> through K covering the actual delivery sequence (intermediate +> hot-fix bundles and superseded dead-end identity tests are not +> surfaced in the phasing — only the conceptual progression is). +> 3. **Two new §P5.14 hazards** (§P5.14.15 and §P5.14.16): +> `iterative_mode` flag forwarding in `mfem::Solver` wrappers (a +> classic non-determinism trap), and `SetSolver` overload selection +> when wiring shared_ptr vs reference (caused the inspector wrap to +> silently bypass the scaled-solver chain during 5.11.K integration). +> +> No content from v6 was removed. All §P5.1–§P5.18 sections, the +> §P5.13 Phase 5.0–5.10 phasing, and the §P5.14.1–§P5.14.14 hazard +> entries survive unchanged. +> +> --- +> +> **Previous version notes (v6 inherits v5 preamble; v5 inherits v4):** +> +> **This document is v6 and supersedes v5 in its entirety.** v6 inlines +> the §P5.18 component-restricted-PBC content section, the new Phase 5.9 +> phasing batch, and the four new §P5.14 hazard entries that were +> originally delivered as a separate addendum after Phase 5.9 +> (component-restricted PBC) shipped and passed validation. **The +> meaningful renumbering**: the v5 doc reserved "Phase 5.9" for the +> performance / GPU / documentation push that closes the Phase 5 +> programme of work; in v6 the component-restricted PBC work (which +> was the actual next implementation batch and is what the source-code +> comments labeled "Phase 5.9 / Batch A.X" during development) takes +> the **Phase 5.9** slot, and the perf/GPU/docs push is renumbered to +> **Phase 5.10**. Code comments and planning doc now agree. +> +> **What v6 adds:** +> +> 1. **New §P5.18 content section** documenting the spec-driven +> constraint-filter machinery end-to-end: the `PeriodicBC` TOML +> interface, the two-axis (pair + component) filter, the EA operator +> row layout under filter, the manager-level rebuild orchestration, +> spec-aware corner essential TDOFs, the SystemDriver per-step hook, +> the stale-cache refresh cascade (with the X-only sizing example +> that caught it), TOML examples, test coverage, backward-compat +> invariants, and future-work pointers. +> 2. **§P5.13 phasing reshuffled**: Phase 5.9 is now "Component- +> restricted PBC (spec-driven constraint filter)" with nine +> sub-batches A through I. The original Phase 5.9 ("Performance +> benchmarks + GPU validation + documentation") becomes Phase 5.10 +> with the same four sub-batches A through D. +> 3. **Four new §P5.14 hazards** (§P5.14.11 through §P5.14.14): +> stale size caches across `MortarSaddlePointSystem` / `m_x_saddle`, +> the SystemDriver mortar ctor ordering invariant, rotation RBMs +> under sub-XYZ specs, and the collective-by-convention scope of +> `Reset`. +> 4. **§P5.6.1 lifecycle code block updated** to inject the +> `SyncMortarPbcForStep(ti)` call inside the `GetUpdateStep` +> transition block, with a §P5.18.6 cross-reference. +> +> No content from v5 was removed. The §P5.8 derivation, the +> classifier-as-cache argument, all existing §P5.4–§P5.7 manager and +> SystemDriver descriptions, and the Phase 5.0–5.8 phasing all +> survive unchanged. +> +> --- +> +> **Previous version notes (v5 inherits v4 preamble; v4 supersedes v3):** +> +> **v4 applies the 13 edits identified during the +> code-review pass against the actual Phase 4 classifier internals +> (`PHASE5_v3_code_review.md`). v3's conceptual core — the §P5.8 UL +> derivation, the build-once-on-reference prescription, the +> classifier-as-cache argument — survived review intact. v4 fixes +> implementation details and resolves one design ambiguity v3 had left +> open. Changes from v3: +> +> **Substantive (v4)** +> +> 1. **Method D explicit** (§P5.8.4 + new §P5.8.4.4). v3 implicitly +> assumed Method-D semantics (iterate on total $v$, constraint +> $Cv = g$ with non-zero $g$) but didn't say so against the alternative +> (Method C — fluctuation primal, homogeneous constraint). v4 picks +> Method D explicitly, justifies the choice, and documents Method C as +> the fallback if the chosen path runs into trouble. +> 2. **Phase 5.0 batch added** (§P5.13). New first batch: +> `MortarSaddlePointSystem` constraint-RHS extension. Phase 4.3 +> modification (~10 LOC) — adds `SetConstraintRHS(const Vector& g)` / +> `ClearConstraintRHS()` methods, modifies `Mult` to subtract `g` from +> the constraint-side residual when set. Default behavior unchanged so +> existing Phase 4.3 tests don't regress. Prerequisite to Phase 5.3 +> (manager construction). +> +> **Interface alignment (v4)** +> +> 3. **K closures passed to manager constructor** (§P5.4.1, §P5.5.3). +> v3's `SetKResidual` / `SetKJacobian` setters don't exist on +> `MortarSaddlePointSystem`; the actual API takes them at construction. +> `MortarPbcManager`'s constructor now takes the closures and forwards +> to the saddle system. +> 4. **`SetConstraintRHS` instead of `GetConstraintRHS`** (§P5.4.1, §P5.4.4). +> The saddle system doesn't expose its RHS for the manager to mutate; +> after the Phase 5.0 extension, the manager calls `SetConstraintRHS(g)`. +> 5. **Saddle-point enums use Phase 4.3 `KrylovType` / `SaddlePrecType` +> directly** (§P5.3.4) with a translation note explaining the option-parser +> boundary. +> 6. **`UpdateMacroscopicF` takes `const mfem::Vector&`, not `DenseMatrix`** +> (§P5.4.1, §P5.4.4). Matches BCManager's flat 9-vector convention. +> 7. **`m_saddle_solver` renamed to `m_saddle_solver_for_init`** (§P5.4.1, +> §P5.4.3). Distinguishes the direct linear solver (used in `SolveInit`) +> from the Newton-loop saddle system. +> 8. **`m_C_op` "refreshed per step" comment removed** (§P5.4.1). It's +> built once. +> +> **Implementation notes (v4)** +> +> 9. **`ProjectVelocityGradientToCornerTDofs` factoring note added** +> (§P5.5.4) — describes the small refactor needed to reuse existing +> `essential_vel_grad` projection logic on a TDOF subset. +> 10. **`ComputeMacroscopicP` LOC estimate added** (§P5.10.3) — Lopes +> eq. 37 implementation is ~30 LOC of new code in `MortarPbcManager`. +> +> **New traps (v4)** +> +> 11. **§P5.14.8** — saddle-point residual must use the set constraint +> RHS, not the homogeneous default. Failure mode: forgetting to +> call `SetConstraintRHS(g)` after `UpdateConstraintRHS()` makes +> Newton converge to the wrong fluctuation. +> 12. **§P5.14.9** — refactoring the corner-pin projection may regress +> non-mortar tests if `UpdateVelocity` is touched too aggressively. +> +> **Open questions deferred to implementation** (from code-review §RR.5) +> +> - Does `SaddlePointSolver` have a generic `Operator&` overload for K? +> (Phase 5.6 SolveInit needs it; verify when implementing.) +> - Hill-Mandel diagnostic implementation feasibility from $\lambda$ + +> classifier data. (Phase 5.8 work.) +> - Restart format support for $\bar F^{(n)}$. (Phase 5.7 / Phase 5.9.) +> - Whether `essential_vel_grad` projection can be factored without +> touching `UpdateVelocity` significantly. (Phase 5.5 work; trap §P5.14.9 +> covers the regression risk.) +> +> **Diff vs v2** (kept here for context across the v2 → v3 → v4 chain): +> v2's per-step `RebuildConstraintBlocks` machinery is gone; the constraint +> matrix is built once and never rebuilt. v3's §P5.8 rewrite established +> this; v4 inherits it without change. The Hill-Mandel diagnostic added in +> v3 stays. Phasing went from 9 batches in v2 → 9 in v3 → 10 in v4 (Phase +> 5.0 added). +> +> **Companion documents**: `MORTAR_PBC_ARCHITECTURE.md`, +> `PHASE4_CPP_PORT_PLAN.md`, `PHASE6_HIGHER_ORDER_LOR.md`. +> **Cross-references**: §X.Y (architecture doc), §P4.X.Y (Phase 4 plan), +> §P5.X.Y (this doc), §P6.X.Y (Phase 6). +> +> **Loading this document into a fresh conversation**: this v3 is sufficient +> context to resume the integration from any phase boundary. Pair with +> `MORTAR_PBC_ARCHITECTURE.md` and `PHASE4_CPP_PORT_PLAN.md` for the +> upstream context. + +--- + +## §P5.1 Goals and non-goals + +### Goals + +1. **Promote `test/mortar_pbc/` to `src/mortar_pbc/`.** After Phase 5 the + user enables mortar PBC by (a) setting `mesh.periodicity = true` in the + TOML and (b) supplying a `velocity_gradient` BC; everything else routes + through existing ExaConstit pathways. +2. **Velocity-primal in updated Lagrangian.** ExaConstit's primal is $v$ + on the current configuration $\Omega^{(n)}$. The mortar PBC constraint + $C\,v = g^{(n)}$ has $C$ built once on the reference (undeformed) face + geometry and an RHS $g^{(n)}$ updated each step from current $\bar L^{(n)}$ + and tracked $\bar F^{(n)}$. See §P5.8 for the rigorous derivation. +3. **Compatible with NR / NRLS / TRDOG; PA / EA / FULL; CPU / GPU; linear + elastic, ExaCMech, UMAT; multi-region heterogeneous meshes.** +4. **Zero regression when mortar is disabled.** The default code path is + bit-identical to pre-Phase-5 ExaConstit. +5. **Reuse `MortarSaddlePointSystem`** (Phase 4.3 Batch R) so the existing + `ExaNewtonSolver` family receives a saddle-point Operator and a + `BlockOperator` Jacobian without per-solver modification. +6. **Reuse `BCManager` and `essential_vel_grad`** for the corner pin; the + only change is restricting the projection from "all TDOFs on tagged + faces" to "8 corner TDOFs from those faces." + +### Non-goals (deferred) + +- $p \ge 2$ primal: see Phase 6 (LOR machinery). +- Semi-periodic (mixed periodic + Dirichlet boundaries): API designed to + accept it cleanly; not Day 1. +- UT BCs as a second `ConstraintAssembler`: long-term roadmap. +- FE² coupling: downstream user. +- Non-axis-aligned RVEs (curvilinear / hexagonal). See §P5.8.9 Gotcha 8 for + the standard fallback if this becomes a need (consistent biorthogonalization + on reference geometry — Phase 5+ infrastructure). + +--- + +## §P5.2 Architectural overview + +Five points of contact with existing code, no new top-level table: + +``` +┌────────────────────────────────────────────────────────────────────┐ +│ TOML — distributed across existing tables │ +│ [Mesh].periodicity, [Mesh].snap_tol, [Mesh].lor_depth (Phase 6) │ +│ [BCs] essential_vel_grad — already exists │ +│ [Solvers.SaddlePoint] — new sibling of [Solvers.Krylov] │ +└────────────────────────────────────┬───────────────────────────────┘ + │ + ┌──────────────────────┴────────────────────┐ + ▼ ▼ + ┌─────────────────────┐ ┌──────────────────────────────┐ + │ BCManager (existing)│ │ SystemDriver (modified) │ + │ single source for │ │ m_mortar_pbc │ + │ essential_vel_grad│ │ detects mortar via │ + │ data │ │ mesh.periodicity │ + └──────────┬──────────┘ │ per-step F̄ update │ + │ │ essential-DOF projection │ + │ (vgrad, comp, ids) └──────────────────┬───────────┘ + ▼ │ + ┌──────────────────────┐ ▼ + │ MortarPbcManager │◀───────────────┐ ┌──────────────────────────────┐ + │ (NEW — owns │ │ │ ExaNewton(LS|TR)Solver │ + │ classifier, │ │ │ ALMOST UNCHANGED. │ + │ builder, │ │ │ Receives a different │ + │ constraint op, │ │ │ Operator (the wrapping │ + │ MortarSaddlePoint- │ │ │ MortarSaddlePointSystem); │ + │ System adapter, │ │ │ GetGradient returns a │ + │ F̄^(n) state) │ │ │ BlockOperator. Krylov on │ + │ │ │ │ the inner block uses the │ + │ Provides: │ │ │ user-chosen MINRES/GMRES/ │ + │ - corner ess TDOFs │ │ │ BiCGStab from │ + │ - F̄^(n) update │ │ │ [Solvers.SaddlePoint] │ + │ - λ warm-start │ │ └───────────────────────────────┘ + │ - C built once, │ │ + │ never rebuilt │ │ + └──────────────────────┘ │ + │ + ┌──────────────┘ + │ saddle-point Operator + │ (constructed once) + ▼ +``` + +The new artifact is `MortarPbcManager`. Everything else is wiring. The +manager's lifecycle is simple: +- **Constructed once** at `SystemDriver` initialization (after + `mech_operator`). +- **Per step**: `UpdateMacroscopicF()` advances $\bar F^{(n)}$ via + $\bar F^{(n+1)} = \bar F^{(n)} + \bar L^{(n+1)} \bar F^{(n)} \Delta t$; + the saddle-point RHS is recomputed. +- **Per Newton iteration**: nothing — the matrix $C$ is constant; only the + bulk equilibrium and the $\lambda$ update happen. + +--- + +## §P5.3 Configuration: distributed across existing TOML tables + +No new `[BCs.MortarPBC]` table. Five additions, each in the table where the +parameter conceptually belongs. + +### §P5.3.1 `[Mesh]` additions + +```toml +[Mesh] +filename = "polycrystal.mesh" +order = 1 + +# Existing flag — promoted to "enable periodic BCs of any flavour". +# When true and a velocity_gradient BC is present, mortar PBC is on. +periodicity = true + +# NEW: snap-coordinate tolerance for face/edge/corner identification +# in BoundaryClassifier3D. Defaults safely; users typically don't touch. +snap_tol = 1.0e-10 + +# NEW (Phase 6 stub): LOR refinement depth on the periodic-face +# ParSubMesh. depth = 1 means no LOR (direct mortar). depth > 1 enables +# higher-order primal via LOR projection. Phase 5 enforces depth = 1; +# Phase 6 lifts the restriction. +lor_depth = 1 +``` + +### §P5.3.2 `[BCs]` — no schema change + +The user supplies the macroscopic deformation rate via the existing +`essential_vel_grad` BC. Same TOML schema as the standard non-mortar path: + +```toml +[BCs] +expt_mono_def_flag = false # existing; unrelated + +[[BCs.essential_vel_grad]] +essential_ids = [1, 2, 3, 4, 5, 6] # all 6 face attributes +essential_comps = [-1, -1, -1, -1, -1, -1] # xyz on each +velocity_gradient = [ + [0.001, 0.0, 0.0], + [0.0, -0.0005, 0.0], + [0.0, 0.0, -0.0005] +] +origin = [0.0, 0.0, 0.0] +``` + +When mortar PBC is enabled, `MortarPbcManager` consumes the +`essential_vel_grad` data the same way `BCManager` does for non-mortar +case — but applies it only to the corner TDOF subset. See §P5.5. + +For semi-periodic (future): the user lists only the *periodic* face +attributes here, with a separate `essential_velocity` block for the +conventionally-Dirichlet faces. The mortar machinery operates on the +`essential_vel_grad`-tagged set; the standard Dirichlet machinery operates +on the rest. Composes naturally because the TDOF sets are disjoint. + +### §P5.3.3 `[Solvers.SaddlePoint]` — new sibling under [Solvers] + +```toml +[Solvers] +assembly = "ea" + +[Solvers.Krylov] # existing — used for K linearization +linear_solver = "GMRES" +rel_tol = 1.0e-12 +abs_tol = 1.0e-30 + +[Solvers.SaddlePoint] # NEW — used for the saddle-point Krylov +linear_solver = "MINRES" # MINRES (default, K symmetric) | GMRES | BICGSTAB +rel_tol = 1.0e-10 +abs_tol = 1.0e-12 +max_iter = 500 +preconditioner = "block_jacobi" # block_jacobi (default) | none +print_level = 0 +``` + +When mortar PBC is disabled, this block is parsed but ignored. Validation +rejects CG (the saddle-point system is indefinite). + +### §P5.3.4 Options struct additions + +In `option_parser_v2.hpp`: + +```cpp +struct MeshOptions { + // ... existing fields ... + bool periodicity = false; // existing + double snap_tol = 1.0e-10; // NEW + int lor_depth = 1; // NEW (Phase 6 stub; enforce == 1 in Phase 5) +}; + +// NEW — option-parser-side enums for the [Solvers.SaddlePoint] table. +// These are TRANSLATED at the construction boundary of MortarPbcManager +// into the Phase 4.3 internal types `mortar_pbc::KrylovType` and +// `mortar_pbc::SaddlePrecType` (defined in saddle_point_solver.hpp). +// Translation lives in `MortarPbcManager` so that option_parser_v2 +// doesn't need to pull in mortar-pbc headers. +enum class SaddlePointSolverType { MINRES, GMRES, BICGSTAB, NOTYPE }; +enum class SaddlePointPreconditioner { BLOCK_JACOBI, NONE, NOTYPE }; + +struct SaddlePointSolverOptions { + SaddlePointSolverType linear_solver = SaddlePointSolverType::MINRES; + double rel_tol = 1.0e-10; + double abs_tol = 1.0e-12; + int max_iter = 500; + SaddlePointPreconditioner preconditioner = SaddlePointPreconditioner::BLOCK_JACOBI; + int print_level = 0; + + bool validate() const; + static SaddlePointSolverOptions from_toml(const toml::value& toml_input); +}; + +struct SolverOptions { + // ... existing fields ... + SaddlePointSolverOptions saddle_point; // NEW +}; +``` + +The translation step inside `MortarPbcManager`: + +```cpp +// In mortar_pbc_manager.cpp: +mortar_pbc::SaddlePointSolverConfig +TranslateSaddleOpts(const SaddlePointSolverOptions& opts) { + mortar_pbc::SaddlePointSolverConfig c; + switch (opts.linear_solver) { + case SaddlePointSolverType::MINRES: c.solver_type = mortar_pbc::KrylovType::MINRES; break; + case SaddlePointSolverType::GMRES: c.solver_type = mortar_pbc::KrylovType::GMRES; break; + case SaddlePointSolverType::BICGSTAB: c.solver_type = mortar_pbc::KrylovType::BiCGStab; break; + default: MFEM_ABORT("invalid SaddlePointSolverType"); + } + switch (opts.preconditioner) { + case SaddlePointPreconditioner::BLOCK_JACOBI: c.prec_type = mortar_pbc::SaddlePrecType::BlockJacobi; break; + case SaddlePointPreconditioner::NONE: c.prec_type = mortar_pbc::SaddlePrecType::None; break; + default: MFEM_ABORT("invalid SaddlePointPreconditioner"); + } + c.rel_tol = opts.rel_tol; + c.abs_tol = opts.abs_tol; + c.max_iter = opts.max_iter; + c.print_level = opts.print_level; + return c; +} +``` + +This way the user-facing TOML strings (`"MINRES"`, `"GMRES"`, etc.) drive +option-parser enums, and option-parser enums translate cleanly to the +Phase 4.3 internal types at the manager boundary. Renaming Phase 4.3's +`KrylovType` / `SaddlePrecType` is explicitly *not* in scope for Phase 5. + +`MortarPbcOptions` from earlier drafts does **not** exist. The configuration +is distributed; `MortarPbcManager` reads what it needs from each existing +struct. + +--- + +## §P5.4 The `MortarPbcManager` class + +Lives in `src/mortar_pbc/mortar_pbc_manager.{hpp,cpp}`. Owns the classifier, +builder, constraint operator, `MortarSaddlePointSystem` adapter, and the +$\bar F^{(n)}$ macroscopic state. + +### §P5.4.1 What the manager owns + +```cpp +namespace exaconstit::mortar_pbc { + +class MortarPbcManager { +public: + /// Function-object types matching the Phase 4.3 + /// `MortarSaddlePointSystem` API. + using KResidualFn = mortar_pbc::MortarSaddlePointSystem::KResidualFn; + using KJacobianFn = mortar_pbc::MortarSaddlePointSystem::KJacobianFn; + + /// Construct: build classifier, builder, EA constraint op, the + /// SaddlePointSolver-for-init, the MortarSaddlePointSystem adapter + /// (using the supplied K closures), the λ warm-start vector, and + /// initialize macroscopic F̄ to identity. + /// Collective on pmesh.GetComm(). + /// + /// The K residual / K Jacobian closures must be supplied at + /// construction time because `MortarSaddlePointSystem` (Phase 4.3 / + /// Batch R) takes them as constructor arguments — there are no + /// setters on the saddle system. SystemDriver constructs both + /// closures from `mech_operator` and passes them in here. + MortarPbcManager(mfem::ParMesh& pmesh, + mfem::ParFiniteElementSpace& fes, + const ExaOptions& opts, + KResidualFn k_residual, + KJacobianFn k_jacobian); + + // ----- Per-step macroscopic F̄ update ----- + + /// Advance F̄ from F̄^(n) to F̄^(n+1) using the current macroscopic L̄ + /// and the time step Δt. First-order update consistent with ExaConstit's + /// existing time stepping: + /// F̄^(n+1) = F̄^(n) + L̄^(n+1) F̄^(n) Δt + /// Called once per time step before the Newton solve. See §P5.8.6 + /// for the derivation. The vgrad input is the same flat 9-vector + /// (row-major 3×3) that BCManager's `UpdateBCData` populates; + /// the manager reshapes it internally to a 3×3 DenseMatrix. + void UpdateMacroscopicF(const mfem::Vector& vgrad_9vec, double dt); + + /// Read access to the current F̄^(n) for diagnostics and the + /// constraint RHS computation. + const mfem::DenseMatrix& GetMacroscopicF() const; + + /// Compute the velocity gradient time derivative Ḟ̄^(n) = L̄^(n) F̄^(n) + /// for the constraint RHS. Cached after each UpdateMacroscopicF. + const mfem::DenseMatrix& GetMacroscopicFdot() const; + + // ----- Corner-Dirichlet TDOFs ----- + + /// The 24 essential TDOFs (8 RVE corners × 3 components). When + /// mortar PBC is enabled, these REPLACE the full-face TDOF list + /// derived from BCManager's essential_vel_grad attributes — + /// that's the entire BC override. See §P5.5. + const mfem::Array& GetCornerEssTDofs() const; + + // ----- The saddle-point Operator ----- + + /// Returns the MortarSaddlePointSystem adapter that wraps the user's + /// K residual/Jacobian closures with the constraint operator. This is + /// what gets handed to ExaNewtonSolver. (Phase 4.3 / Batch R machinery + /// extended in Phase 5.0 with constraint-RHS support.) + MortarSaddlePointSystem& GetSaddleSystem(); + + /// The direct-linear-saddle-point solver used by SystemDriver's + /// SolveInit (one-shot linear correction at BC ramp boundaries). + /// NOT used inside the Newton loop — that goes through the + /// MortarSaddlePointSystem adapter above. + SaddlePointSolver& GetSaddleSolverForInit(); + + // ----- Constraint RHS update ----- + + /// Recompute the saddle-point RHS using the current F̄^(n) and L̄^(n), + /// and call `m_saddle_system->SetConstraintRHS(g)` to install it. + /// Called once per time step after UpdateMacroscopicF, before the + /// Newton solve. The RHS depends on macroscopic state but not on the + /// current iterate, so it is fixed for the duration of the Newton + /// iteration. See §P5.8.6. + void UpdateConstraintRHS(); + + // ----- λ accumulation ----- + + /// The Lagrange multiplier vector. Persists across Newton iterations + /// and time steps for warm-starting. + mfem::Vector& GetLambda(); + void ResetLambda(); + int NumLocalLmRows() const; + + // ----- Diagnostics ----- + + /// Compute the rank-local C·v for printing the constraint residual + /// after a Newton solve. + void ComputeConstraintResidual(const mfem::Vector& v, + mfem::Vector& Cv) const; + + /// Compute v_tilde = v - v_lin on the current mesh, for visualization. + /// v_lin is computed from the corner pin formula in spatial form + /// (§P5.8.7). Cached reference corner coords are NOT used here — + /// v_lin is the spatial-form macroscopic field on the current mesh. + void ComputeFluctuationField(const mfem::Vector& v_tdofs, + mfem::ParGridFunction& v_tilde_gf) const; + + /// Hill-Mandel power balance diagnostic. Returns + /// |P̄ : Ḟ̄ - (1/|Ω₀|) ∫_Ω₀ P : Ḟ dΩ| + /// which should be zero to FP precision at any converged step + /// (Hill 1963; Mandel 1971). See §P5.8.11. The macroscopic P̄ is + /// recovered from the assembled λ via Lopes et al. eq. 37 — see + /// §P5.10.3 for the implementation note. + double ComputeHillMandelPowerBalance() const; + +private: + mfem::ParMesh& m_pmesh; + mfem::ParFiniteElementSpace& m_fes; + const ExaOptions& m_opts; + + // Built once at construction; never modified after. + std::unique_ptr m_classifier; + std::unique_ptr m_builder; + std::unique_ptr m_C_op; + std::unique_ptr m_saddle_solver_for_init; + std::unique_ptr m_saddle_system; + + mfem::Array m_corner_ess_tdofs; + mfem::Vector m_lambda; + + // Per-step macroscopic state. + mfem::DenseMatrix m_macro_F; // F̄^(n) — initialized to I, advanced per step + mfem::DenseMatrix m_macro_Fdot; // Ḟ̄^(n) = L̄^(n) F̄^(n) — cached for RHS + + // Buffer for the per-step constraint RHS g^(n) handed to + // m_saddle_system->SetConstraintRHS(g). Persists across the Newton + // iteration; refreshed in UpdateConstraintRHS each step. + mfem::Vector m_g_rhs; + + // Cached at construction; used in UpdateConstraintRHS (§P5.8.6.d). + // Per LM row: ∫_{Γ₀} M_i dΓ₀ × (X⁺ - X⁻)_pair_axis(i) + mfem::DenseMatrix m_rhs_geometric_factors; +}; + +} // namespace +``` + +### §P5.4.2 The `MortarSaddlePointSystem` integration + +`MortarSaddlePointSystem` (Phase 4.3 Batch R) is an `mfem::Operator` that +composes user-supplied K residual + Jacobian closures with the EA constraint +operator into a unified saddle-point Operator: + +- `Mult(x_block, y_block)` returns the saddle-point residual: + $y = (\,F_\text{int}(v) + C^T \lambda - 0,\; C v - g^{(n)}\,)^T$. +- `GetGradient(x_block)` returns the `BlockOperator` representing + $\begin{bmatrix} K & C^T \\ C & 0 \end{bmatrix}$. + +The manager constructs this once at startup with the user's +`NonlinearMechOperator` driving the K closures. The constraint matrix $C$ +in the adapter never changes; only the RHS $g^{(n)}$ in the residual +computation updates per step (controlled by `UpdateConstraintRHS`). + +This is what makes §P5.7 simple: existing solvers see a regular +`mfem::Operator`; they don't need to know about saddle-point structure. + +### §P5.4.3 Construction sequence + +```cpp +MortarPbcManager::MortarPbcManager(mfem::ParMesh& pmesh, + mfem::ParFiniteElementSpace& fes, + const ExaOptions& opts, + KResidualFn k_residual, + KJacobianFn k_jacobian) + : m_pmesh(pmesh), m_fes(fes), m_opts(opts), + m_macro_F(3), m_macro_Fdot(3) +{ + CALI_CXX_MARK_SCOPE("mortar_pbc::manager::construct"); + + // 1. Classifier on the parent ParMesh — built once on the initial + // (undeformed) mesh. Phase 4.2 distributed pair matching runs here. + // The classifier caches reference face geometry in + // QuadFaceElement::coords / TriFaceElement::coords arrays + // transiently during BuildLocalPairBlocks; after Initialize() + // returns, only the assembled D / A_m blocks remain. The + // classifier is therefore decoupled from pmesh's subsequent + // motion (§P5.8.5). + m_classifier = std::make_unique( + pmesh, fes, opts.mesh.snap_tol); + m_classifier->Initialize(); + + // 2. Builder — also built once; topology only. + m_builder = std::make_unique(*m_classifier); + + // 3. EA constraint op on the reference (initial) mesh. Built once; + // NEVER rebuilt. See §P5.8.5 for why this is correct under UL. + m_C_op = std::make_unique(*m_classifier); + + // 4. Direct-linear saddle-point solver for SolveInit (one-shot + // linear corrections at BC ramp boundaries). Configured from + // [Solvers.SaddlePoint] options via the option-parser-side + // enum translation (see §P5.3.4). + m_saddle_solver_for_init = std::make_unique( + TranslateSaddleOpts(opts.solvers.saddle_point)); + + // 5. Adapter: composes the K closures with the constraint op into + // a single mfem::Operator presenting the saddle-point system to + // the Newton solver. The K closures must be supplied at + // construction time (Phase 4.3 / Batch R API; no setters + // available). With Phase 5.0's constraint-RHS extension, the + // adapter also accepts a non-zero constraint RHS via + // `SetConstraintRHS`; we install that per step in + // UpdateConstraintRHS(). + m_saddle_system = std::make_unique( + std::move(k_residual), std::move(k_jacobian), *m_C_op); + + // 6. Corner TDOFs from the classifier's CornerInfo3D records. + BuildCornerEssTDofs(); + + // 7. λ warm-start. + m_lambda.SetSize(m_builder->NumLocalRows()); + m_lambda.UseDevice(true); + m_lambda = 0.0; + + // 8. Constraint-RHS buffer. + m_g_rhs.SetSize(m_builder->NumLocalRows()); + m_g_rhs.UseDevice(true); + m_g_rhs = 0.0; + + // 9. Macroscopic F̄ initialized to identity (no prior deformation). + m_macro_F = 0.0; + m_macro_F(0, 0) = m_macro_F(1, 1) = m_macro_F(2, 2) = 1.0; + m_macro_Fdot = 0.0; + + // 10. Cache RHS geometric factors (§P5.8.6.d). Computed once on the + // reference; reused for every step's UpdateConstraintRHS. + BuildReferenceGeometricFactors(); +} +``` + +The `BuildReferenceGeometricFactors()` step computes, per LM row, the +quantities $\hat \ell^{\hat E_0}_i \cdot (X^+ - X^-)_{\text{pair}(i)}$ from +(P5.8.6.d). These are constants over the simulation lifetime. + +### §P5.4.4 Per-step state update + +```cpp +void MortarPbcManager::UpdateMacroscopicF(const mfem::Vector& vgrad_9vec, + double dt) +{ + CALI_CXX_MARK_SCOPE("mortar_pbc::manager::update_macro_F"); + + // BCManager populates vgrad_9vec as a flat row-major 3×3 (the same + // convention used by the existing essential_vel_grad code path). + // Reshape it locally into a 3×3 DenseMatrix. + mfem::DenseMatrix L_macro(3); + { + const auto VG = vgrad_9vec.HostRead(); + auto LM = L_macro.HostWrite(); + for (int i = 0; i < 3; ++i) { + for (int j = 0; j < 3; ++j) { + LM[i * 3 + j] = VG[i * 3 + j]; + } + } + } + + // F̄^(n+1) = F̄^(n) + L̄^(n+1) F̄^(n) Δt + mfem::DenseMatrix delta_F(3); + mfem::Mult(L_macro, m_macro_F, delta_F); + delta_F *= dt; + m_macro_F += delta_F; + + // Ḟ̄^(n+1) = L̄^(n+1) F̄^(n+1) (post-update value used in RHS) + mfem::Mult(L_macro, m_macro_F, m_macro_Fdot); +} + +void MortarPbcManager::UpdateConstraintRHS() +{ + CALI_CXX_MARK_SCOPE("mortar_pbc::manager::update_rhs"); + + // Per LM row i: g_i = Ḟ̄^(n) · m_rhs_geometric_factors[i,:] + // where m_rhs_geometric_factors[i,:] is the cached + // ∫M_i dΓ₀ · (X⁺-X⁻)_pair(i) vector. + auto FACTORS = m_rhs_geometric_factors.HostRead(); + auto FDOT = m_macro_Fdot.HostRead(); + auto G = m_g_rhs.HostWrite(); + const int nrows = m_builder->NumLocalRows(); + + for (int i = 0; i < nrows; ++i) { + double gi = 0.0; + for (int k = 0; k < 3; ++k) { + for (int l = 0; l < 3; ++l) { + // Component-aware unrolling: the LM row's component is + // determined by which displacement component the + // homologous-pair offset acts in. + gi += FDOT[3*k + l] * FACTORS[i*3 + l]; + } + } + G[i] = gi; + } + + // Install the new RHS into the saddle-point adapter. The adapter + // (Phase 4.3 + Phase 5.0 extension) will subtract this from r_C + // in its Mult, so the Newton converges when C v - g = 0 (the + // Method-D form, see §P5.8.4.4). + m_saddle_system->SetConstraintRHS(m_g_rhs); +} +``` + +### §P5.4.5 Lifetime and ownership + +- `MortarPbcManager` is held by `SystemDriver` as a `std::unique_ptr`. +- It receives the parent `ParMesh` and `ParFiniteElementSpace` by reference; + lifetime is managed by `SimulationState` upstream. +- All mortar machinery (classifier, builder, $C$ op, saddle solver / system) + is owned by the manager. +- $\lambda$ persists across Newton iterations (warm-start within step) and + across time steps (warm-start at next step's first Newton iteration). +- $\bar F$ persists across time steps as macroscopic state and must be + saved / restored on checkpoint / restart (§P5.14.1). + +--- + +## §P5.5 Corner-Dirichlet via the existing BCManager / `mono_def_flag` pattern + +`BCManager` continues to own all BC data. Mortar PBC introduces a single +override: when projecting `essential_vel_grad` BCs to the mech operator's +essential-TDOF list, use the *corner-only subset* of TDOFs from those +attributes, not the full-face set. + +This is structurally identical to how `mono_def_flag` already operates: the +flag redirects the BC application path through a different branch in +`SystemDriver::UpdateEssBdr` and `NonlinearMechOperator::UpdateEssTDofs`. +Mortar PBC adds a parallel flag `m_mortar_enabled` in `SystemDriver` that +does the same kind of selective override. + +### §P5.5.1 The override hook + +`NonlinearMechOperator` gains one new method: + +```cpp +class NonlinearMechOperator : public mfem::NonlinearForm { +public: + // ... existing ... + + /// Phase 5 — replace the mech operator's essential TDOF list with + /// the supplied set. Mirrors UpdateEssTDofs's mono_def_flag branch + /// but takes a TDOF list directly instead of an attribute mask. + /// Used by mortar PBC to inject the 8-corner-only TDOF set. + void UpdateEssTDofsCornerSubset(const mfem::Array& corner_tdofs); +}; +``` + +Internally this calls `ParNonlinearForm::SetEssentialTrueDofs` with the +corner list. From the operator's perspective, nothing else changes — `Mult` +and `GetGradient` zero-eliminate the corner rows just as they would for any +Dirichlet TDOF. + +### §P5.5.2 BC value computation: the corner pin in spatial form + +The corner pin in UL spatial form is + +$$ +v_\text{corner}^{(n)} = \bar L^{(n)} \cdot (x_\text{corner}^{(n)} - x_0) +$$ + +where $x_\text{corner}^{(n)}$ is the *current* corner coordinate and $x_0$ is +the user-supplied origin. ExaConstit's existing `essential_vel_grad` +machinery computes exactly this (verified in `system_driver.cpp`: +`mesh->GetNodes()` returns current spatial coords). The mortar override +just restricts the projection to corner TDOFs; the formula is unchanged. + +**Origin assumption**: this works correctly for $X_0 = (0,0,0)$ (the +standard convention for axis-aligned RVEs). For $X_0 \ne 0$, see +§P5.8.9 Gotcha 3 — the recommended workflow is to pre-translate the mesh +so the loading origin is at $(0,0,0)$. + +### §P5.5.3 SystemDriver wiring + +```cpp +// In SystemDriver constructor, AFTER mech_operator is constructed +// (the K closures need to capture it): +if (options.mesh.periodicity && HasVelocityGradientBC(options)) { + m_mortar_enabled = true; + + // Build the K closures up front. They capture mech_operator as + // a raw pointer; the manager outlives mech_operator only if the + // SystemDriver guarantees it (asserted at manager destruction). + auto k_residual = + [op_ptr = mech_operator.get()](const mfem::Vector& v, + mfem::Vector& r) { + op_ptr->Mult(v, r); + }; + auto k_jacobian = + [op_ptr = mech_operator.get()](const mfem::Vector& v) + -> mfem::Operator* { + // mech_operator->GetGradient returns by reference; convert + // to the pointer the saddle-system's KJacobianFn expects. + return &op_ptr->GetGradient(v); + }; + + // Construct the manager with the closures up front. The manager's + // ctor builds the MortarSaddlePointSystem internally (Phase 4.3 + // Batch R adapter takes K closures at construction; there are + // no setters). + m_mortar_pbc = std::make_unique( + *m_sim_state->GetMesh(), + *m_sim_state->GetMeshParFiniteElementSpace(), + options, + std::move(k_residual), + std::move(k_jacobian)); + + // Override essential TDOFs to the corner subset. + mech_operator->UpdateEssTDofsCornerSubset( + m_mortar_pbc->GetCornerEssTDofs()); + + // Hand the saddle-point Operator to the Newton solver in place + // of mech_operator. + newton_solver->SetOperator(m_mortar_pbc->GetSaddleSystem()); +} else { + // Standard non-mortar path — unchanged. + newton_solver->SetOperator(mech_operator); +} +``` + +### §P5.5.4 `UpdateEssBdr` and `UpdateVelocity` + +`SystemDriver::UpdateEssBdr`'s existing logic queries `BCManager` for the +essential_vel_grad TDOF mask and updates `mech_operator`. Under mortar PBC +we keep the corner-only TDOF override: + +```cpp +void SystemDriver::UpdateEssBdr() { + if (!mono_def_flag) { + BCManager::GetInstance().UpdateBCData( + ess_bdr, ess_bdr_scale, ess_velocity_gradient, ess_bdr_component); + if (m_mortar_enabled) { + // Corner TDOFs are step-invariant; re-asserting them is + // a no-op but cheap and clearer than skipping the call. + mech_operator->UpdateEssTDofsCornerSubset( + m_mortar_pbc->GetCornerEssTDofs()); + } else { + mech_operator->UpdateEssTDofs(ess_bdr["total"], mono_def_flag); + } + } +} +``` + +`UpdateVelocity` similarly grows one branch — when mortar enabled, project +the velocity-gradient onto the corner TDOFs only, using the *current* mesh +nodes through the existing essential_vel_grad spatial-form code path. + +**Refactoring note**: the body of the existing `UpdateVelocity`'s +essential_vel_grad branch (in `system_driver.cpp`) does +$v(x) = \nabla v \cdot (x - x_0)$ across all TDOFs of the tagged +attributes. To reuse this on a TDOF subset, factor the per-node loop +into a small free function: + +```cpp +void ProjectVelocityGradientToCornerTDofs( + const mfem::ParGridFunction& mesh_nodes, + const mfem::Vector& vgrad_9vec, + const mfem::Vector& origin, + const mfem::Array& corner_tdofs, + mfem::Vector& v_tdofs); +``` + +The function reads `mesh->GetNodes()` (current spatial coords) and +applies $v = \nabla v \cdot (x - x_0)$ at each TDOF in `corner_tdofs`, +writing into `v_tdofs` at those positions and leaving other entries +unchanged. The non-mortar `UpdateVelocity` path can be refactored to +call this same helper with `corner_tdofs = ess_tdofs` (all +essential_vel_grad TDOFs), making the mortar branch a one-line +substitution. **Trap §P5.14.9 covers the regression risk** if this +refactor is done sloppily. + +--- + +## §P5.6 SystemDriver `Solve()`, `SolveInit()`, and the per-step lifecycle + +### §P5.6.1 The new step lifecycle + +The main time-stepping loop in `mechanics_driver.cpp` already has the right +shape: + +```cpp +while (!sim_state->IsFinished()) { + if (BCManager::GetInstance().GetUpdateStep(ti)) { + oper.SyncMortarPbcForStep(ti); // §P5.18.6 — install or + // switch active periodic-BC + // entry for this step. No-op + // for non-mortar runs and for + // steps that don't transition. + oper.UpdateEssBdr(); + oper.UpdateVelocity(); + oper.SolveInit(); + } + if (m_mortar_enabled) { + // Advance F̄^(n) using the current macroscopic L̄^(n+1) and Δt. + // No constraint matrix rebuild — see §P5.8 for the derivation. + // ess_velocity_gradient is the SystemDriver member that BCManager + // has just populated for this step (in UpdateEssBdr above). + m_mortar_pbc->UpdateMacroscopicF(ess_velocity_gradient, GetCurrentDt()); + m_mortar_pbc->UpdateConstraintRHS(); + } + oper.UpdateModel(); + oper.Solve(); + sim_state->FinishCycle(); + // ... output, advance time ... + ti++; +} +``` + +The mortar-specific addition is two cheap calls: `UpdateMacroscopicF` (one +3x3 matrix multiply) and `UpdateConstraintRHS` (one matvec on a small dense +table). Both run before `UpdateModel` and `Solve`. **No constraint matrix +rebuild, no per-Newton-iteration refresh.** The matrix $C$ inside +`m_C_op` is what was constructed at simulation start and stays. + +### §P5.6.2 `Solve()` — minimal change + +Because the `MortarSaddlePointSystem` adapter is what's been handed to +`newton_solver` (§P5.5.3), the Newton body itself doesn't need a new code +path: + +```cpp +void SystemDriver::Solve() { + auto x = m_sim_state->GetPrimalField(); + if (auto_time) { /* ... existing initial-guess setup ... */ } + + mfem::Vector zero; + newton_solver->Mult(zero, *x); // operates on the saddle-point + // BlockVector when m_mortar_enabled + // ... existing convergence assertion ... +} +``` + +The "BlockVector when mortar enabled" detail is handled by the adapter: +`MortarSaddlePointSystem::Mult` knows to pack/unpack the $\lambda$ block +internally, so the outer `Mult` signature is still `(input, output)` on +`mfem::Vector`. The existing `ExaNewtonSolver::Mult` body — convergence +check, residual norm, line search — operates on the saddle-point operator +transparently. + +The convergence criterion correction (the residual must include the +$C^T \lambda$ term and the constraint residual, see architecture doc §12 +Trap 3) is internal to `MortarSaddlePointSystem::Mult`: it returns the +combined residual that the Newton solver sees, so existing rel/abs +tolerances apply naturally. + +### §P5.6.3 `SolveInit()` — corrector with constraint awareness + +The `SolveInit` corrector solves +$K_\text{eliminated} \cdot \Delta v_\text{free} = -K \cdot \Delta v_\text{ess}$ +for the BC ramp-up. With mortar PBC the corrector becomes a saddle-point +system: + +$$ +\begin{bmatrix} K_\text{eliminated} & C^T \\ C & 0 \end{bmatrix} +\begin{bmatrix} \Delta v \\ \Delta \lambda \end{bmatrix} = +\begin{bmatrix} -K \cdot \Delta v_\text{ess} \\ -(C v_\text{prev} - g^{(n)}) \end{bmatrix} +$$ + +The right-hand side $-(Cv_\text{prev} - g^{(n)})$ is typically tiny if the +previous step converged (constraint was satisfied) and the new step's $g$ +is close to the prev's (small loading change); it captures the correction +needed for any accumulated drift plus the loading delta. The left block is +the same $C$ used in the Newton, with $K$ from +`mech_operator->GetUpdateBCsAction(...)`. + +Implementation: the existing `SolveInit` body is augmented with a mortar +branch that constructs the saddle-point RHS and calls +`m_mortar_pbc->GetSaddleSolver().Solve(K_uc, *m_C_op, b, r2, du, dlam)` +directly (bypassing the Newton wrapper since this is a single linear solve). + +--- + +## §P5.7 Newton-solver integration via `MortarSaddlePointSystem` + +`MortarSaddlePointSystem` does the heavy lifting; the existing +`ExaNewtonSolver` family stays nearly unchanged. + +### §P5.7.1 What stays the same in `ExaNewtonSolver` family + +- Class structure (NR, NRLS, TRDOG). +- `Mult(b, x)` signature. +- The convergence loop. +- The line-search logic (NRLS). +- The trust-region logic (TRDOG). + +### §P5.7.2 What changes + +Nothing structural. The solver is handed a different `mfem::Operator`: +when mortar PBC is on, the operator is `MortarSaddlePointSystem` and +`GetGradient` returns a `BlockOperator`. The adapter's internals take care +of the saddle-point Krylov on the inner solve. + +The only solver-side adjustment is that the inner-Krylov preconditioner — +what would normally be the user's choice from `[Solvers.Krylov]` — for the +mortar case is bypassed in favour of the `[Solvers.SaddlePoint]` +block-Jacobi preconditioner that the adapter constructs from +`MortarConstraintOperator::ComputeInvDiagSchur`. This is automatic: the +adapter's `GetGradient` returns a `BlockOperator` plus an associated +`BlockDiagonalPreconditioner`; the existing `ExaNewtonSolver::Mult` calls +`prec->SetOperator(grad)` and then `prec->Mult(r, c)`, which routes through +MFEM's saddle-point Krylov infrastructure. + +### §P5.7.3 NRLS: line search merit function + +The NRLS line search picks $\varepsilon$ minimizing $\|F\|$. With the +saddle-point Operator, $F$ is the combined residual; the adapter's `Mult` +returns the right thing automatically. NRLS line search correctness flows +from the adapter; no NRLS code modification needed. + +### §P5.7.4 TRDOG: the K^T concern + +Trust region uses $J$ and $J^T$ on the block Jacobian. The block transpose is + +$$ +\mathcal{J}^T = \begin{bmatrix} K^T & C^T \\ C & 0 \end{bmatrix} +$$ + +(remember: the off-diagonal $C^T$ appears in both $\mathcal{J}$ and +$\mathcal{J}^T$ — they don't swap when you take the block transpose). When +$K$ is symmetric (linear elastic, hyperelastic, most ExaCMech tangents +under standard formulations), $\mathcal{J}^T = \mathcal{J}$ and TRDOG works +without modification. + +Your recent BBar PA `MultTranspose` work covers the case of $K^T$ being +explicitly needed; when wired into `mech_operator->GetGradient(x)` returning +a `MultTranspose`-capable operator, the `MortarSaddlePointSystem`'s block +Jacobian also exposes `MultTranspose` correctly (composes the per-block +transposes through the standard `BlockOperator::MultTranspose` path). + +--- + +## §P5.8 The mortar PBC constraint under Updated Lagrangian — theoretical justification, mathematical derivation, and failure modes + +This section is the mathematical and conceptual core of v3. It justifies +why the constraint matrix $C$ can be built once on reference geometry and +reused throughout the simulation, despite ExaConstit's bulk being UL with +velocity primal — a combination that does not appear to have been published +elsewhere. + +### §P5.8.1 The continuous PBC constraint in UL spatial form + +#### Kinematic statement + +For a representative volume element (RVE) on the cube $\Omega_0 = [0, L]^3$ +in the *reference* (initial, undeformed) configuration, periodicity is the +statement that the displacement fluctuation $\tilde u$ is identical on +homologous points across opposing faces: + +$$ +\tilde u(X^+) = \tilde u(X^-) +\qquad \forall\, (X^+, X^-) \text{ homologous on } \partial \Omega_0 +$$ + +where the total displacement is split as $u(X) = (\bar F - I)\, X + \tilde u(X)$ +into a macroscopic affine part and a periodic fluctuation, and homologous +pairs $(X^+, X^-)$ are reference-coordinate pairs related by lattice +translation: $X^+ = X^- + L\,\hat e_k$ for some axis $\hat e_k$. + +This is the standard formulation found in Hill (1963), Mandel (1971), and +all subsequent computational homogenization literature. In displacement +form, the homologous-pair constraint is + +$$ +u(X^+) - u(X^-) = (\bar F - I)\,(X^+ - X^-). +\tag{P5.8.1.a} +$$ + +#### Conversion to velocity-primal UL form + +ExaConstit solves for the spatial velocity field $v(x, t) = \dot x$ on the +*current* (deformed) configuration $\Omega^{(n)}$. The mesh nodes advance +per step via $x^{(n+1)} = x^{(n)} + v^{(n+1)}\,\Delta t$, and the constitutive +law sees the relative deformation gradient $F_{\text{rel}}^{(n)} = \partial x^{(n+1)}/\partial x^{(n)}$. + +Differentiating (P5.8.1.a) in time: + +$$ +\dot u(X^+) - \dot u(X^-) = \dot{\bar F}\,(X^+ - X^-). +\tag{P5.8.1.b} +$$ + +Since $\dot u$ at a material point equals the spatial velocity at the +current position of that point, this is + +$$ +v(x^+(t)) - v(x^-(t)) = \dot{\bar F}\,(X^+ - X^-). +\tag{P5.8.1.c} +$$ + +To convert to fully spatial form, observe that for a homologous pair the +*current* coordinate offset is + +$$ +x^+ - x^- = X^+ + u(X^+) - X^- - u(X^-) + = (X^+ - X^-) + \bigl[u(X^+) - u(X^-)\bigr] + = (X^+ - X^-) + (\bar F - I)\,(X^+ - X^-) + = \bar F\,(X^+ - X^-), +\tag{P5.8.1.d} +$$ + +where the second-to-last step uses (P5.8.1.a) at converged equilibrium. +Substituting: + +$$ +\dot{\bar F}\,(X^+ - X^-) = \dot{\bar F}\, \bar F^{-1}\,(x^+ - x^-) = \bar L\,(x^+ - x^-), +$$ + +since the macroscopic spatial velocity gradient is defined as +$\bar L = \dot{\bar F}\,\bar F^{-1}$. So (P5.8.1.c) becomes + +$$ +\boxed{\;v(x^+) - v(x^-) = \bar L\,(x^+ - x^-)\;} +\tag{P5.8.1.e} +$$ + +This is the **velocity-primal spatial-form periodic constraint** that +ExaConstit must enforce. Both $v(x^\pm)$ and $x^\pm$ are evaluated at the +*current* configuration $\Omega^{(n)}$; $\bar L^{(n)}$ is the macroscopic +spatial velocity gradient at step $n$ (a user-supplied loading rate). + +#### Remarks + +(a) The equivalence (P5.8.1.c) ↔ (P5.8.1.e) is exact for the converged +periodic field, regardless of how large the macroscopic deformation has +become. The constraint can be expressed purely in current-config quantities +($v$, $x$, $\bar L$) without explicit reference to $\bar F$. This is the +form ExaConstit naturally works with — and crucially, the form the existing +`essential_vel_grad` BC machinery already uses for non-mortar loading. + +(b) Equation (P5.8.1.d) is *not* generally true off-equilibrium (during a +non-converged Newton iteration, the fluctuation field $\tilde u$ is not yet +periodic), so the relation $x^+ - x^- = \bar F(X^+ - X^-)$ is approximate +mid-iteration. The right-hand side of the discrete constraint will use +*current* $x^\pm$ as read from the moving mesh; the constraint itself drives +the field toward (P5.8.1.e) at convergence. We discuss this iteration-vs-converged +distinction further in §P5.8.6. + +### §P5.8.2 The mortar weak form and its integration domain + +#### Discrete weak form + +Introduce a dual-basis Lagrange multiplier $\lambda_h \in M_h(\partial \Omega^-)$ +on the nonmortar surface (Wohlmuth, *SIAM J. Numer. Anal.* 38, 2000; +Wohlmuth, *Discretization Techniques and Iterative Solvers* book, 2001). The +Lagrange multiplier $\lambda$ has the physical interpretation of the +surface traction needed to enforce periodicity (Lopes et al. 2021, eq. 37, +recovers this for the closely related uniform-traction case). The mortar +weak form of (P5.8.1.e) is + +$$ +\int_\Gamma M_i(\xi) \cdot \bigl[\,v_h^+(\xi) - v_h^-(\xi) - \bar L\,(x_h^+(\xi) - x_h^-(\xi))\,\bigr]\, d\Gamma = 0, +\quad \forall i, +\tag{P5.8.2.a} +$$ + +where $M_i$ is a Wohlmuth dual-basis function and $\Gamma$ is the integration +domain. The *choice of $\Gamma$* is the central operational question. + +Two natural options: + +- **Choice A (reference)**: $\Gamma = \Gamma_0$, the reference (undeformed) + nonmortar surface. The integrand uses reference parametrization; the area + measure $d\Gamma_0$ is constant per element on an axis-aligned RVE. + +- **Choice B (current)**: $\Gamma = \Gamma^{(n)}$, the deformed nonmortar + surface at step $n$. The integrand uses current parametrization; the area + measure $d\Gamma^{(n)}$ varies per quadrature point under non-trivial + deformation. + +Both are mathematically valid weak forms of the same continuous constraint. +Both give convergent discrete approximations as the mesh refines. They +differ in the *discrete approximation properties* — most importantly, in +whether the Wohlmuth dual basis preserves its biorthogonality. + +#### Conversion of integration measures + +Under deformation, the relationship between $d\Gamma_0$ and $d\Gamma^{(n)}$ +is given by Nanson's formula: + +$$ +d\Gamma^{(n)} = J^{(n)}\,\bigl|F^{-T,(n)} \hat n_0\bigr|\, d\Gamma_0 +\equiv \theta^{(n)}(\xi)\, d\Gamma_0, +\tag{P5.8.2.b} +$$ + +where $J^{(n)} = \det F^{(n)}$ is the bulk Jacobian and $\hat n_0$ is the +reference outward normal. The factor $\theta^{(n)}(\xi)$ varies pointwise on +the parent face element under non-trivial deformation. This is the source +of the biorthogonality issue under Choice B (next subsection). + +#### Discretization + +Using $v_h^\pm = \sum_j v_j^\pm N_j(\xi)$ and the dual-basis property, the +weak form (P5.8.2.a) discretizes to + +$$ +D_{ii} v_i^- - \sum_j A^m_{ij} v_j^+ = g_i^{(n)}, +\tag{P5.8.2.c} +$$ + +with + +$$ +D_{ii} = \int_\Gamma M_i \, N_i \, d\Gamma, +\qquad +A^m_{ij} = \int_\Gamma M_i \, (N_j \circ \Pi)\, d\Gamma, +\qquad +g_i^{(n)} = \bar L^{(n)} \cdot \int_\Gamma M_i\,(x^+ - x^-)\, d\Gamma. +\tag{P5.8.2.d} +$$ + +Here $\Pi$ is the homologous-pair projection. Stack over all nonmortar DOFs +to get the system $C v = g^{(n)}$ where $C = [-D \;|\; A^m]$ acts on the +stacked nonmortar / mortar TDOF vector. + +The matrices $D$ and $A^m$ depend on $\Gamma$ — i.e., on Choice A vs B. The +right-hand side $g^{(n)}$ depends on both $\Gamma$ and the current +$\bar L^{(n)}$ / $x^\pm$. + +### §P5.8.3 Wohlmuth biorthogonality on reference faces — why this gives clean discrete behavior + +#### The biorthogonality property + +Wohlmuth (2000) constructs the dual basis $M_i$ on a reference parent +element $\hat E$ via a coefficient transformation + +$$ +M_i(\xi) = \sum_j A^{\hat E}_{ij}\, N_j(\xi), +\qquad +A^{\hat E} = \bigl(\hat M^{\hat E}\bigr)^{-1}\, \mathrm{diag}\bigl(\hat \ell^{\hat E}\bigr), +\tag{P5.8.3.a} +$$ + +with $\hat M^{\hat E}_{ij} = \int_{\hat E} N_i N_j \, d\hat\xi$ the parent +mass matrix and $\hat \ell^{\hat E}_j = \int_{\hat E} N_j \, d\hat\xi$ the +lumped row-sums. By construction (Wohlmuth 2000, Lemma 2.1): + +$$ +\int_{\hat E} M_i(\xi)\, N_j(\xi)\, d\hat\xi = \delta_{ij}\, \hat \ell^{\hat E}_j. +\tag{P5.8.3.b} +$$ + +This is the **biorthogonality property**: in the reference parametric +domain, the dual basis is orthogonal to the standard basis up to the lumped +row-sums. This makes $D$ diagonal (cheap to invert) — the operational +advantage of dual mortar over Lagrange-multiplier mortar. + +#### What happens under deformation + +When we evaluate $D$ via Choice A (reference integration), we compute + +$$ +D^A_{ii} = \int_{E_0} M_i(\xi)\, N_i(\xi)\, |J^{E_0}_\text{face}|\, d\hat\xi + = |J^{E_0}_\text{face}| \int_{\hat E} M_i\, N_i\, d\hat\xi + = |J^{E_0}_\text{face}|\, \hat \ell^{\hat E}_i, +\tag{P5.8.3.c} +$$ + +where $|J^{E_0}_\text{face}|$ is the *constant* parent-to-physical Jacobian +of the reference axis-aligned face. The integral factors cleanly because +$|J|$ is constant; biorthogonality is preserved at the assembled level. +**$D$ is exactly diagonal.** + +Under Choice B (current integration), we compute + +$$ +D^B_{ii} = \int_{E^{(n)}} M_i\, N_i\, |J^{E,(n)}_\text{face}(\xi)|\, d\hat\xi. +\tag{P5.8.3.d} +$$ + +The factor $|J^{E,(n)}_\text{face}(\xi)|$ varies *pointwise* on the parent +under non-trivial deformation (per Nanson's formula, P5.8.2.b — the factor +$\theta^{(n)}(\xi)$ is non-constant when the deformation gradient has any +spatial variation across the face). The integral does not factor; the +parent-element coefficients $A^{\hat E}_{ij}$ from (P5.8.3.a) no longer give +biorthogonality at the assembled level. **$D$ becomes full (no longer +diagonal), and the dual mortar's algorithmic advantage is lost.** + +#### Restoring biorthogonality: consistent biorthogonalization + +Popp, Gee, & Wall (*IJNME* 79, 2009) and Popp, Wohlmuth, Gee, & Wall +(*SIAM J. Sci. Comput.* 34, 2012) developed **consistent biorthogonalization** +to restore diagonality of $D$ under Choice B: reconstruct $A^{\hat E}_{ij}$ +*per element per iteration* using the deformed-element mass matrix +$\hat M^{E,(n)}_{ij} = \int_{\hat E} N_i N_j\, |J^{(n)}|\, d\hat\xi$. This +makes the dual basis itself deformation-dependent; correctness then +requires the dual basis to be linearized w.r.t. nodal positions in the +consistent Newton tangent (Popp et al. 2010, *IJNME* 84:543). + +This is what mortar contact codes do (Tribol uses *standard* — non-dual — +multipliers and avoids the issue; BACI/4C does consistent biorthogonalization; +see Wohlmuth & Popp's overview chapters in *Mortar Methods for Single- and +Multi-Field Applications*, Springer 2014). It works but is substantially +more expensive than the reference-integration approach — every Newton +iteration rebuilds $A^{\hat E}$ for every face element, and the linearization +adds tangent terms. **For RVE-PBC where this expense is unnecessary, Choice +A is strictly better.** + +#### The RVE-PBC literature consensus + +Reis & Pires (*CMAME* 274, 2014, eq. 14–17) and Lopes, Ferreira, & Pires +(*CMAME* 384, 2021, §3) build $D$ and $A^m$ on the reference RVE faces, in +total Lagrangian frameworks with displacement primal. In both cases the +matrices are constructed once at preprocessing and reused throughout the +simulation. This is the standard practice in computational homogenization +mortar PBC, justified by the constant-Jacobian property of axis-aligned RVE +reference faces and the resulting biorthogonality preservation. + +### §P5.8.4 The constraint matrix is configuration-blind and primal-blind + +This is the key conceptual point that justifies applying the literature's +reference-integration technique to ExaConstit's UL bulk. + +#### Configuration-independence of $C_{ij}$ + +The matrix entries (P5.8.2.d) are *pure geometric integrals* on whichever +$\Gamma$ is chosen. They do not contain $u$, $v$, or any field variable. +Once $\Gamma$ is fixed, the matrix entries are deterministic functions of +the chosen face geometry alone: + +$$ +C^A_{ij} = \int_{\Gamma_0} M_i\, N_j\, d\Gamma_0 +\quad\text{(pure geometric integral on reference faces)}. +\tag{P5.8.4.a} +$$ + +Construct (P5.8.4.a) once at simulation start, store the resulting sparse +matrix, and the *values* $C^A_{ij}$ are valid for the rest of the simulation. +The mesh advancing under UL does not change these values because the +integration domain $\Gamma_0$ is reference geometry, not pmesh's current +state. + +#### Primal-independence of $C_{ij}$ + +The matrix $C^A$ is a linear operator on a TDOF vector. The TDOF vector can +hold *any* field defined on the same FES — displacement $u$, velocity $v$, +displacement increment $\Delta u$, or anything else. The operator's action + +$$ +[C^A w]_i = \sum_j C^A_{ij}\, w_j +\tag{P5.8.4.b} +$$ + +is a pure linear-algebra operation; it doesn't know what $w$ represents +physically. So the *same* matrix $C^A$ that encodes the displacement-form +constraint $C^A u = g^{TL}(\bar F, X)$ in a TL framework also encodes the +velocity-form constraint $C^A v = g^{UL}(\bar L, x)$ in a UL framework +**provided we update the right-hand side appropriately**. + +The discrete velocity constraint becomes + +$$ +C^A\, v = g^{(n)} +\qquad\text{with}\qquad +g_i^{(n)} = \bar L^{(n)} \cdot \int_{\Gamma_0} M_i\,(x^{+,(n)} - x^{-,(n)})\, d\Gamma_0, +\tag{P5.8.4.c} +$$ + +which uses the same matrix $C^A$ but a UL-aware RHS computed from current +$x^\pm$ (read from current pmesh). + +#### Why this works across formulation boundaries + +The kinematic relationship $v^+ - v^- = \bar L(x^+ - x^-)$ is a statement +about *fields and their spatial coordinates*, not about which integration +domain the discrete weak form uses. Picking $\Gamma_0$ for the discrete +weak form gives one consistent discrete approximation; picking $\Gamma^{(n)}$ +gives a different but equally valid one. Both converge to the same +continuous constraint as $h \to 0$ (standard mortar convergence theory — +Wohlmuth 2000, Theorem 4.1; Bernardi, Maday, & Patera 1994, *IMA J.* 14). + +So the right thing for ExaConstit is to: + +1. Build $C^A$ once on $\Gamma_0$ (reference, undeformed faces). Get + biorthogonality automatically. +2. Apply $C^A$ to velocity TDOFs — this is the UL-primal velocity constraint. +3. Update the RHS $g^{(n)}$ each step using current $x^\pm$ and current + $\bar L^{(n)}$. +4. The mesh advancing under UL doesn't touch $C^A$ — the matrix is + configuration-blind. + +This is operationally the same as what Reis & Pires / Lopes do, but with +the TDOFs interpreted as velocity rather than displacement and the RHS +computed in spatial form rather than material form. The **technique is +established; the application to UL bulk is novel** (see §P5.8.8). + +#### §P5.8.4.4 Method D explicit: iterate on total $v$, with non-zero $g$ + +The continuous constraint (P5.8.1.e) discretizes (§P5.8.4.c) to +$C^A v = g^{(n)}$ with $g^{(n)} \ne 0$ in general. There are two +operationally distinct ways to *enforce* this discrete constraint +inside a Newton solver: + +- **Method C — fluctuation primal, homogeneous constraint.** Define + $v_\text{lin}(x) = \bar L^{(n)} \cdot (x - x_0)$ as a separate field + computed each step; let the iterate be the fluctuation + $\tilde v = v_\text{total} - v_\text{lin}$; the constraint becomes + homogeneous: $C^A \tilde v = 0$. The corner pin sets + $\tilde v_\text{corner} = 0$. This matches the existing + `MortarSaddlePointSystem::Mult` API as written (which hardcodes + $r_C = C \cdot u$ — homogeneous). Bulk equilibrium computes + $F_\text{int}(v_\text{lin} + \tilde v)$ inside the K residual closure. + +- **Method D — total primal, non-zero RHS.** The iterate is the total + velocity $v$; the constraint is $C^A v = g^{(n)}$ with $g^{(n)}$ + computed from $\dot{\bar F}^{(n)}$ as in (P5.8.6.d). The corner pin + sets $v_\text{corner} = \bar L^{(n)} \cdot x^{(n)}_\text{corner}$. + Bulk equilibrium computes $F_\text{int}(v)$ directly — no addition + step needed inside the K closure. + +**ExaConstit Phase 5 picks Method D.** The reasons: + +1. **Primal-field convention compatibility.** ExaConstit's + `m_sim_state->GetPrimalField()` is the total velocity vector; all + downstream plumbing (`UpdateEndCoords`, the constitutive law, + `PostProcessingDriver`) expects total $v$. Method C would require + maintaining $\tilde v$ as the iterate while still synthesizing total + $v$ everywhere downstream — significant plumbing churn. + +2. **Matches the patch test pattern.** The Phase 4 patch test driver + uses Method D (total primal, $u_\text{init} = u_\text{lin}$, + constraint RHS computed via $-(C u_\text{init} - g)$). Production + stays consistent with the validated linear-case path. + +3. **Localized API extension.** Method D needs one small Phase 4.3 + modification: a `SetConstraintRHS(const Vector&)` method on + `MortarSaddlePointSystem` to install $g^{(n)}$ each step. Default + behavior with no $g$ set remains the homogeneous form, so existing + Phase 4.3 tests don't regress. This is the **Phase 5.0 batch** + in the phasing (§P5.13). + +4. **No K-closure-side complication.** The K closures in Method D are + simply `mech_operator->Mult(v, r)` and `mech_operator->GetGradient(v)` + — direct delegation. Method C's K closures would need to add + $v_\text{lin}$ to the iterate before delegating, which couples the + K closure to per-step macroscopic state and forces explicit + $v_\text{lin}$ tracking. + +The trade-off Method D accepts is the Phase 4.3 modification (Phase 5.0 +batch) and the macroscopic $\bar F^{(n)}$ tracking in `MortarPbcManager` +(needed to compute $g$ from $\dot{\bar F}^{(n)} = \bar L^{(n)} \bar F^{(n)}$). +Both are small and contained. + +**Method C as fallback.** If Method D runs into trouble during +implementation (e.g., the saddle-point Krylov struggles with the +non-zero RHS for some material configuration, or the $\bar F$ tracking +has a subtle drift the diagnostic doesn't catch), Method C is the +documented fallback. It requires an internal pivot — turning the +mech-operator's K closure into a wrapper that adds $v_\text{lin}$ — +but doesn't touch the saddle-point system itself. The validation tests +in §P5.8.11 should detect Method-D-specific failures early enough to +make the pivot before deep production integration. + +### §P5.8.5 Operational realization: the classifier as reference-face cache + +#### The cache mechanism + +`BoundaryClassifier3D::Initialize()` walks `pmesh.GetVertex(...)` for each +boundary face element at simulation start and stores the corner coordinates +in `QuadFaceElement::coords` / `TriFaceElement::coords` arrays. These arrays +are owned by the classifier and are never updated thereafter. + +`face_mortar_assembler_3d.cpp`'s `NonmortarJacobian`, +`NonmortarJacobianAxisAligned`, and the per-quadrature integration loops in +`AssemblePairConforming` and `AssembleQuadFacePairClipped` read from those +cached arrays — never from `pmesh`. So once the classifier is initialized, +the constraint matrix is built against a frozen-in-time copy of the initial +face geometry. + +This is the operational realization of "build $C^A$ on $\Gamma_0$" without +maintaining a separate reference mesh: the classifier is itself the +reference-face cache. The cost is minimal (one `Vector` of corner coords +per boundary face element; ~$O(n^2)$ memory for an $n^3$ RVE). + +#### What is not cached + +Three things are *not* cached and must be computed per step: + +- **The current $\bar L^{(n)}$** — pulled from `BCManager::GetVelocityGradient` + on the time-dependent series. +- **The current $\bar F^{(n)}$** — tracked as `MortarPbcManager::m_macro_F` + state, advanced per step via (P5.8.6.f). +- **The current macroscopic loading rate $\dot{\bar F}^{(n)} = \bar L^{(n)} \bar F^{(n)}$** — + computed once per step from the above two, used in the constraint RHS. + +#### What stays constant + +- Sparsity pattern of $C^A$ (pair list, gtdof maps, Wohlmuth boundary tags). +- Numerical values of $C^A$ (the integrals (P5.8.4.a) on reference geometry). +- The per-element parent-coefficient matrices $A^{\hat E}_{ij}$. +- The non-conforming clipping topology (Phase 4.4 BVH and clip vertices) + if non-conforming faces are used — these too are reference-mesh quantities + cached at classifier init. +- The cached `m_rhs_geometric_factors` table in `MortarPbcManager` + (§P5.4.4) — per LM row, $\hat \ell^{\hat E_0}_i \cdot (X^+ - X^-)_{\text{pair}(i)}$ + precomputed once. + +#### Opposite-normal face pairs and the `mortar_node_perm` + +A geometric fact worth stating explicitly here, because implementers +routinely get this wrong: **the conforming face-pair node permutation +$\texttt{mortar\_node\_perm}$ is never the identity on opposite-normal +periodic faces of an axis-aligned cube.** Face elements are conventionally +ordered CCW from outside the volume; for $+x$ and $-x$ faces "outside" +points in opposite directions, so the in-plane $(y, z)$ CCW order is +reversed between the two faces. `NodePermByCoordMatch` correctly reports +the involution $(0, 3, 2, 1)$ for matched quad-quad pairs (or $(0, 2, 1)$ +for tri-tri pairs). + +The conforming assembler's inner loop must account for this by mapping the +nm reference Gauss point into the *mortar's own* reference frame via the +perm-defined affine map (which `MortarRefFromPermutation` does correctly) +and then evaluating the standard reference basis there. **No further +reordering of the resulting shape values is needed or correct.** Applying +a second permutation on the shape values is a subtle bug class that is +invisible at $n = 2$ resolution (because every face element is then +corner-tagged with $M \equiv 1$ on its support, which makes the shape +permutation algebraically irrelevant) but breaks the Wohlmuth row-sum +identity at $n \geq 3$ with a precise $C v / g = 1.5$ signature at +corner-adjacent face-interior rows. See §P5.14.10 for the full failure +mode, mathematical reproduction, and detection diagnostic. + +### §P5.8.6 The right-hand side update under UL + +#### Spatial form RHS + +From (P5.8.4.c): + +$$ +g_i^{(n)} = \bar L^{(n)} \cdot \int_{\Gamma_0} M_i(\xi)\,(x^{+,(n)}(\xi) - x^{-,(n)}(\xi))\, d\Gamma_0. +\tag{P5.8.6.a} +$$ + +This is a "weighted current-spatial-offset" integral. We can simplify using +the reference homologous-pair structure of the RVE. For axis-aligned RVEs, +the reference offset is constant per face pair: $(X^+ - X^-)_k = L\,\hat e_k$ +on the $k$-th periodic axis. Under macroscopic deformation, by (P5.8.1.d) at +converged equilibrium and on the corner-pinned faces: + +$$ +x^{+,(n)} - x^{-,(n)} = \bar F^{(n)}\,(X^+ - X^-) + (\tilde u^+ - \tilde u^-). +\tag{P5.8.6.b} +$$ + +The fluctuation difference $\tilde u^+ - \tilde u^-$ vanishes at converged +equilibrium (that's exactly the constraint we're enforcing), so + +$$ +x^{+,(n)} - x^{-,(n)} \to \bar F^{(n)}\,(X^+ - X^-) = L\,\bar F^{(n)}\hat e_k +\quad\text{(at equilibrium)}. +\tag{P5.8.6.c} +$$ + +This is *constant per face pair* in the converged state, independent of +$\xi$. The integral in (P5.8.6.a) factors: + +$$ +g_i^{(n)} = \bar L^{(n)}\, \bar F^{(n)}\, (X^+ - X^-)_{\text{pair}(i)}\, \int_{\Gamma_0} M_i\, d\Gamma_0 + = \dot{\bar F}^{(n)}\, (X^+ - X^-)_{\text{pair}(i)}\, \hat \ell^{\hat E_0}_i, +\tag{P5.8.6.d} +$$ + +since $\bar L \bar F = \dot{\bar F}$ and $\int_{\Gamma_0} M_i\, d\Gamma_0 += \hat \ell^{\hat E_0}_i$ by Wohlmuth's lumped-row property on reference +geometry. + +So in spatial form *at converged equilibrium*, the RHS reduces to a product +of (constant reference quantities) × ($\dot{\bar F}^{(n)}$) — the current +macroscopic deformation rate. We don't need to evaluate quadrature-point-level +current spatial offsets at all in the converged state. + +#### Off-equilibrium considerations + +Mid-Newton-iteration, the fluctuation difference $\tilde u^+ - \tilde u^-$ +is *not* zero — that's what the Newton iteration is correcting. A naive +implementation of (P5.8.6.a) using current quadrature-point spatial offsets +would feed the *unconverged* fluctuation back into the constraint RHS, +causing the Newton to chase a moving target. The cleaner choice is to use +the converged-form RHS (P5.8.6.d), which depends only on $\dot{\bar F}^{(n)}$ +and reference geometry, independent of the current $\tilde u$. + +This is a subtle but important point: **the constraint RHS should be +expressed in terms of the macroscopic loading rate, not the current boundary +spatial offsets**. Operationally this means tracking $\bar F^{(n)}$ as +state and computing $\dot{\bar F}^{(n)} = \bar L^{(n)} \bar F^{(n)}$ for +the RHS update. + +#### Practical $\bar F^{(n)}$ tracking + +ExaConstit's existing infrastructure does not currently track macroscopic +$\bar F$ as state. We add a small piece of state in `MortarPbcManager`: +a $3 \times 3$ `mfem::DenseMatrix m_macro_F` updated each step via + +$$ +\bar F^{(n+1)} = \exp\bigl(\bar L^{(n+1)}\,\Delta t\bigr)\, \bar F^{(n)} +\quad\text{(matrix exponential)} +\tag{P5.8.6.e} +$$ + +or first-order via + +$$ +\bar F^{(n+1)} = \bar F^{(n)} + \bar L^{(n+1)}\,\bar F^{(n)}\,\Delta t. +\tag{P5.8.6.f} +$$ + +For consistency with ExaConstit's existing first-order time integration, +(P5.8.6.f) is the natural choice. The extra cost is one matrix-matrix +product per step — negligible. + +The RHS (P5.8.6.d) is then computed once per step (not per Newton iteration) +using $\bar F^{(n)}$, and the saddle-point Newton iterates against this +fixed RHS until convergence. Fluctuations correct themselves; the +macroscopic loading is locked in. + +#### Equivalence to the standard `essential_vel_grad` corner pin + +For a corner DOF, where the fluctuation is *defined* to be zero (the corner +is pinned by Dirichlet, not by mortar), $x^{(n)}_\text{corner} = \bar F^{(n)} X_\text{corner}$ +holds *exactly*, not just at equilibrium. The corner pin then is: + +$$ +v_\text{corner}^{(n)} = \dot{\bar F}^{(n)}\, X_\text{corner} + = \bar L^{(n)} \bar F^{(n)}\, X_\text{corner} + = \bar L^{(n)}\, x_\text{corner}^{(n)}, +\tag{P5.8.6.g} +$$ + +where the last equality uses $x_\text{corner}^{(n)} = \bar F^{(n)} X_\text{corner}$ +(pinned, no fluctuation). This is exactly what ExaConstit's existing +`essential_vel_grad` BC machinery computes via $v(x) = \bar L \cdot (x^{(n)} - x_0)$ +(with $x_0 = 0$ for axis-aligned RVEs with the standard origin). **No +formula change is needed for the corner pin in mortar PBC; the existing +`essential_vel_grad` projection, restricted to the corner TDOF subset, +gives the right answer.** + +### §P5.8.7 Why the corner pin alone is not sufficient — the need for the mortar constraint + +A common question: if the corner pin enforces $v_\text{corner} = \bar L\, x_\text{corner}$ +at 8 vertex DOFs, why isn't that enough to enforce periodicity? + +**Because the corner pin enforces only the macroscopic affine velocity at 8 +points. The macroscopic field at any other point on the face is determined +by the macroscopic loading, but the *fluctuation* part of the velocity +field is not controlled.** The mortar constraint enforces that the +fluctuation part is periodic — i.e., that homologous interior face DOFs +have *equal* fluctuation values across opposing faces. + +Together: +- The corner pin (8 corners × 3 components = 24 strong Dirichlet TDOFs) + fixes the macroscopic affine velocity exactly at the 8 RVE corners. +- The mortar constraint (one LM row per interior face / edge nonmortar TDOF + after Wohlmuth corner / edge sentinel removal) enforces fluctuation + periodicity on all other boundary TDOFs. + +This split — strong corner pin + weak mortar fluctuation periodicity — is +what gives the formulation its variational consistency and what makes the +homogenized stress recoverable from the assembled Lagrange multipliers +(Lopes et al. 2021, eq. 37). + +### §P5.8.8 What is novel and what is established + +#### Established techniques being reused + +1. **Reference-integrated mortar PBC** with Wohlmuth dual basis — + established by Reis & Pires 2014, Lopes et al. 2021 in TL + displacement-primal frameworks for computational homogenization. The + technique itself is mature; the biorthogonality preservation argument is + rigorous and well-cited. +2. **Wohlmuth dual basis with corner / edge modifications** — established by + Wohlmuth 2000–2007 (collected in *Discretization Techniques and Iterative + Solvers Based on Domain Decomposition*, Springer 2001). The wirebasket + hierarchy used by Phase 4 is a direct application of these modifications. +3. **Updated Lagrangian crystal plasticity** — established for ExaConstit's + bulk equilibrium via standard finite-element formulations (Bonet & Wood, + *Nonlinear Continuum Mechanics for FEM*, CUP 2008; Belytschko, Liu, & + Moran, *Nonlinear FEM*, Wiley 2014). The mech_operator + UpdateEndCoords + pattern is conventional. +4. **`MortarSaddlePointSystem` adapter** for embedding the saddle-point + structure into a regular `mfem::Operator` interface — Phase 4.3 / Batch + R, conceptually based on standard saddle-point Krylov techniques (Benzi, + Golub, & Liesen, *Acta Numer.* 14, 2005). + +#### What is novel — to my knowledge + +The novelty is in the *combination*: applying the reference-integration +mortar PBC technique to a UL bulk crystal plasticity solver, and doing so +with velocity primal in the mortar weak form. Specifically: + +(N1) **The matrix-construction-is-primal-blind argument** in §P5.8.4 is, I +believe, the right way to justify the application but does not appear +explicitly in either the RVE-PBC literature (which is TL) or the contact +mortar literature (which is UL but for a different problem with sliding +interfaces). The argument hinges on the observation that the matrix +*values* are pure geometric integrals — the same numbers in both +formulations — so the literature's reference-integration technique transfers +to UL with no algebraic modification. + +(N2) **The classifier-as-reference-face-cache realization** in §P5.8.5 is +specific to the ExaConstit / MFEM architecture. By keeping the classifier +authoritative for face geometry and decoupled from the moving pmesh, we get +the operational equivalent of "integrate on reference" without maintaining +a full reference mesh. + +(N3) **The macroscopic $\bar F$ tracking via (P5.8.6.f)** is needed for the +constraint RHS in spatial form. ExaConstit's existing infrastructure +doesn't track this — it's a new piece of state. The convention that +$\bar F$ is updated per step using the current $\bar L^{(n+1)}$ via a +first-order matrix product is the same convention used in TL homogenization +codes (e.g., the macro-scale time integration in Lopes et al.) but applied +inside a UL solver loop. + +To my knowledge based on basic literature searches, no paper combines (i) +mortar PBC, (ii) UL bulk formulation with velocity primal, and (iii) +finite-strain crystal plasticity. Most computational homogenization papers +using mortar PBC (Reis & Pires; Lopes; Schneider & Andra et al.) work in TL +displacement-primal frameworks; UL crystal plasticity papers (e.g. Nervi & +Idiart 2015, *IJP* 65; Roters et al. *DAMASK*) typically use Lagrange +multipliers or master-slave constraints, not mortar. + +#### Why this should work despite being novel + +The reasoning chain is: + +(R1) The **continuous constraint** (P5.8.1.e) is well-defined on the current +configuration regardless of bulk formulation choice. + +(R2) **The mortar weak form** (P5.8.2.a) admits two integration domain +choices, both valid; each gives a convergent discrete approximation +(Wohlmuth 2000, Theorem 4.1). + +(R3) Choice A (reference integration) preserves Wohlmuth biorthogonality +exactly (P5.8.3.c) on axis-aligned RVE faces. This is the same property +exploited by all RVE-PBC mortar literature. + +(R4) The **matrix entries** under Choice A are configuration-blind +geometric integrals; the matrix is built once and reused for the simulation +lifetime regardless of how the pmesh advances under UL. + +(R5) The **operator action** $C v$ on velocity TDOFs is a pure linear +operation; the matrix doesn't know whether it's being applied to $u$ or $v$. +So the RVE-PBC literature's matrix construction transfers verbatim to a +velocity-primal UL setting. + +(R6) The **right-hand side** updates per step using current $\bar L^{(n)}$ +and tracked $\bar F^{(n)}$, giving the discrete velocity-form constraint +$C v = g^{(n)}$ (P5.8.4.c). + +(R7) The **convergence properties** of the discrete approximation are +inherited from the standard mortar method (Wohlmuth 2000; Bernardi-Maday-Patera +1994). + +The argument is reasonably tight. The places where it could fail are +documented in §P5.8.9. + +### §P5.8.9 Gotchas — where this picture may break down + +The following are the documented or anticipated failure modes. Each is +flagged with **severity** and a **diagnostic** (how the user / developer +would notice the failure). + +#### Gotcha 1 (severe): Severe boundary distortion violates homologous-pair geometric meaning + +**Failure mode**: When some part of the RVE boundary undergoes severe local +distortion — e.g., a face element collapses near zero area, a face flips +over, or strong macro-shear bands cause faces to rotate by > 90° — the +physical meaning of "homologous-point pair" can become ambiguous. The +reference-coordinate-defined pairing $(X^+, X^-)$ is well-defined +mathematically but may no longer correspond to physically meaningful +periodicity. + +**Diagnostic**: Volume-averaged $\langle F \rangle$ drifts substantially +from the prescribed $\bar F^{(n)}$; the $\tilde v$ visualization shows +non-trivial (non-periodic) fluctuation that doesn't go to zero at converged +homogeneous steps; Newton convergence rate degrades (typically linear or +worse). The Hill-Mandel diagnostic (§P5.10.3) flags drift > 1e-10 relative. + +**Mitigation in Phase 5**: this regime is outside the scope of mortar PBC +based on either configuration (current-integration mortar would suffer from +the same physical breakdown). Document that ExaConstit's mortar PBC is +intended for "macro-scale-uniform" deformation regimes where the RVE +remains a meaningful representative volume; for shear-band-forming or +strain-localizing problems, the user should consider domain-decomposition +methods or adaptive remeshing instead. + +#### Gotcha 2 (significant): The constraint RHS expression assumes $\bar F^{(n)}$ tracking is consistent with the bulk integration + +**Failure mode**: $\bar F^{(n)}$ is updated externally using the user's +prescribed $\bar L^{(n)}$. If the user's prescribed $\bar L^{(n)}$ does not +faithfully describe the macroscopic loading the bulk equilibrium "wants" +(e.g., because of large boundary fluctuation that the corner pin cannot +suppress, or because of nonlinearity in the macroscopic response that the +user did not account for in time-stepping $\bar L$), then the RHS computed +from $\bar F^{(n)}$ is inconsistent with the actual macroscopic deformation +in the bulk. + +**Diagnostic**: $\langle F \rangle$ from the bulk solution does not match +the externally-tracked $\bar F^{(n)}$ at converged steps. Hill-Mandel power +balance non-zero. + +**Mitigation in Phase 5**: register $\langle F \rangle$ computation and the +Hill-Mandel power balance as per-step outputs. Cross-check against +$\bar F^{(n)}$. If the user's $\bar L$ time-history gives a $\bar F^{(n)}$ +inconsistent with $\langle F \rangle$, the right action is *on the user +side*: they need to refine their loading description. This is not an +implementation bug. + +#### Gotcha 3 (moderate): The corner pin assumes $X_0 = (0,0,0)$ for axis-aligned RVEs with origin-anchored loading + +**Failure mode**: ExaConstit's `essential_vel_grad` allows a user-supplied +`vgrad_origin` $x_0$. For mortar PBC consistency, $x_0$ should be the +*reference* origin, and the corner pin should use $\bar L \cdot (x^{(n)} - x_0^{(n)})$ +where $x_0^{(n)} = \bar F^{(n)} X_0$ tracks the deforming origin. With the +existing `essential_vel_grad` machinery, $x_0$ is treated as fixed in space. +For $X_0 = 0$, $x_0^{(n)} = 0$ trivially and no issue arises. For +$X_0 \ne 0$, the existing machinery gives wrong corner pin values. + +**Diagnostic**: Volume-averaged $\langle F \rangle$ has a non-zero offset +from the prescribed $\bar F^{(n)}$ proportional to $X_0$. + +**Mitigation in Phase 5**: document that mortar PBC assumes $X_0 = 0$ at +construction. If the user's RVE has a non-zero reference origin, recommend +they translate the mesh to put the origin at $(0,0,0)$ before running. This +is the standard convention in computational homogenization. + +**Future fix (Phase 5+ or Phase 6)**: extend `essential_vel_grad` to +optionally accept a "reference origin" interpretation that uses +$x_0^{(n)} = \bar F^{(n)} X_0$. Small change; deferred until a real user +needs it. + +#### Gotcha 4 (moderate): Wohlmuth biorthogonality at $p \ge 2$ breaks even on the reference + +**Failure mode**: At $p \ge 2$, the reference parent-element mass matrix +$\hat M^{\hat E}_{ij}$ has off-diagonal entries that don't go away with +constant $|J|$. The closed-form coefficients $A^{\hat E}_{ij}$ no longer +give exact biorthogonality on assembly; they give an *approximate* +biorthogonality with $O(h)$ consistency error in $H^1$ rates. + +**Diagnostic**: Patch test passes at engineering tolerance but $H^1$ +convergence rate falls below the optimal $h^p$. + +**Mitigation in Phase 5**: $p = 1$ only. Phase 6's LOR machinery resolves +this — refining the periodic-face submesh by $p-1$ levels and running the +mortar pipeline at LOR Q1/P1 restores the constant-$|J|$ property at the +LOR refinement level. + +#### Gotcha 5 (low): Time integrator mismatch under multi-stage schemes + +**Failure mode**: ExaConstit currently uses first-order time stepping, +which is consistent with the (P5.8.6.f) update for $\bar F^{(n)}$. If a +higher-order time scheme (Newmark, generalized-α, BDF2) is added later, +the macroscopic $\bar F$ tracking must use the *same* multi-stage scheme as +the bulk equilibrium time integration; otherwise the constraint RHS is +inconsistent with the bulk's intermediate-stage configurations. + +**Diagnostic**: stress/strain hysteresis at converged steady-state; growing +$|\langle F \rangle - \bar F^{(n)}|$ over many time steps. + +**Mitigation in Phase 5**: not relevant — first-order matches first-order. +Documented for forward extensibility. + +#### Gotcha 6 (low): Non-conforming face matching on severely deformed reference geometry + +**Failure mode**: The Phase 4.4 BVH and Sutherland-Hodgman clip topology +are built once on the reference faces. If the reference geometry has +degenerate or near-degenerate face elements (e.g., a Neper-generated mesh +with extreme aspect ratios near grain boundaries), the clipping topology +may have quality issues that aren't apparent until the simulation deforms +significantly. + +**Diagnostic**: $C v$ residual doesn't go to zero at converged steps; large +condition number on `SaddlePointSolver`. + +**Mitigation in Phase 5**: standard mesh-quality sanity checks at classifier +construction (boundary face-element aspect ratio, minimum area). If a face +element's reference area is below a tolerance, emit a warning; suggest mesh +quality improvement. + +#### Gotcha 7 (low): The constraint primal must match the bulk primal + +**Failure mode**: If for any reason the bulk equilibrium ends up using a +displacement-form residual (e.g., because the user accidentally sets a +displacement-style essential_vel BC), the constraint $C v = g$ is operating +on the wrong primal. The mortar constraint enforces velocity periodicity +when the bulk is enforcing displacement equilibrium — internally inconsistent. + +**Diagnostic**: Newton fails to converge from the first iteration; residual +doesn't decrease at all. + +**Mitigation in Phase 5**: assert at `MortarPbcManager` construction that +the mech_operator's primal field is velocity (it is — +`m_sim_state->GetPrimalField()` is always the velocity TDOF vector in +ExaConstit). This is structurally guaranteed by ExaConstit's design but +worth asserting defensively. + +#### Gotcha 8 (theoretical): Non-axis-aligned RVEs + +**Failure mode**: For non-axis-aligned reference geometry (e.g., a +hexagonal RVE for hcp polycrystals, or a curvilinear-boundary RVE for +additive manufacturing), $|J^{E_0}_\text{face}|$ is not constant per +element — it varies even on the reference. The constant-$|J|$ argument in +§P5.8.3 fails; biorthogonality is approximate even before any deformation. + +**Diagnostic**: Phase 4.4 patch test fails on a non-axis-aligned RVE — this +is detectable at the unit-test level before the full simulation runs. + +**Mitigation in Phase 5**: out of scope. Phase 5 supports axis-aligned RVEs +only, validated by the Phase 4.1 patch test suite. Curvilinear RVEs are an +explicit non-goal (architecture doc §13.3 long-term, with Tribol as the +likely matching backend). + +### §P5.8.10 Potential fallbacks if the assumption fails + +| Failure mode | Recovery path | Cost | +|---|---|---| +| Gotcha 1 (severe distortion) | Out of scope for mortar PBC. Switch to domain decomposition or adaptive remeshing. | High (different formulation) | +| Gotcha 2 ($\bar F$ inconsistency) | User-side: refine $\bar L$ time history. | Low | +| Gotcha 3 ($X_0 \ne 0$) | Pre-translate mesh, OR extend `essential_vel_grad` for reference-origin interpretation. | Low (translate) or Medium (BC extension) | +| Gotcha 4 ($p \ge 2$ biorthogonality) | Phase 6 LOR. | Medium (Phase 6 work) | +| Gotcha 5 (multi-stage time integration) | Match macroscopic $\bar F$ stepping to bulk scheme. | Low at higher-order time scheme implementation | +| Gotcha 6 (mesh quality) | Mesh quality improvement on user side. | Low | +| Gotcha 7 (primal mismatch) | Assert defensively; should never trigger. | Trivial | +| Gotcha 8 (non-axis-aligned RVE) | Out of scope for Phase 5. Phase 5+ direction: Tribol-based matching, or Popp-Wohlmuth consistent biorthogonalization on the reference. | High (new infrastructure) | + +The most consequential fallback is for Gotcha 8 if non-axis-aligned RVEs +become a priority: **switch to consistent biorthogonalization on the +reference**. This is the same machinery contact mortar codes use, applied to +reference geometry instead of current. The construction is: + +1. Per face element, compute $\hat M^{E_0}_{ij} = \int_{\hat E} N_i N_j |J^{E_0}|\, d\hat\xi$ + on the *reference* geometry (which has non-constant $|J^{E_0}|$ for + non-axis-aligned RVEs but is still time-invariant). +2. Solve $\hat M^{E_0} A^{E_0} = \mathrm{diag}(\hat \ell^{E_0})$ for the + per-element coefficient matrix $A^{E_0}_{ij}$. +3. Use $A^{E_0}_{ij}$ instead of the closed-form $A^{\hat E}_{ij}$ in the + dual basis evaluation. +4. Build $D$ on the reference using these element-specific coefficients — + $D$ becomes diagonal again. + +This is computed once at preprocessing (not per Newton iteration as in +contact mortar) because the reference geometry is time-invariant. The +overhead is per-element linear-solve cost at preprocessing; runtime is +unchanged. + +This Gotcha 8 fallback is "Phase 5+ infrastructure" — flag in the doc, do +not implement in Phase 5. If a user demands curvilinear RVEs, the fallback +is well-defined and the implementation cost is bounded. + +### §P5.8.11 Validation strategy for the UL adaptation + +Phase 4 patch tests validate the technique at small deformation (single +load step, ~5% strain). Phase 5 needs explicit validation that the build-once +approach extends correctly to UL multi-step finite deformation. + +#### Test 1: large total strain via multi-step monotonic loading (new) + +**Setup**: a Q1 hex RVE, single-grain Neohookean material, simple shear +loading at $\bar L_{xy} = 0.01\,/\text{s}$ for 100 time steps with +$\Delta t = 0.5\,\text{s}$. Total accumulated strain at end: ~50%. + +**PASS criteria**: +- $\langle F \rangle^{(n)}$ matches tracked $\bar F^{(n)}$ at every step to + FP precision (Hill–Mandel power balance). +- $\tilde v$ visualization is small in magnitude relative to $v_\text{lin}$ + at every step (indicating clean fluctuation periodicity throughout). +- Newton convergence rate is asymptotically quadratic at every step + (indicating saddle-point system is well-conditioned and consistent). + +If all three pass, the build-once-on-reference approach is empirically +validated for large-deformation single-grain UL. + +#### Test 2: heterogeneous polycrystal (existing, extend) + +The existing Phase 4 strip-split and checkerboard tests at single load step +validate matrix construction. Extend to 50 time steps of monotonic loading +(same $\bar L_{xy}$ ramp), with periodically-saved checkpoints for restart +testing. + +**PASS criteria**: same as Test 1, plus matched homogenized stress +$\langle \sigma \rangle$ at intermediate checkpoints to FP precision (no +drift between save / restore cycles indicates state consistency). + +#### Test 3: cross-validation against a TL prototype (optional but valuable) + +If we have access to a TL displacement-primal mortar PBC implementation +(Lopes' code, or a Python prototype written for cross-validation), run the +same problem in both and compare: + +- Homogenized stress $\langle \sigma \rangle$ at every step +- $\tilde u$ field at every step +- $\lambda$ values at every step + +PASS: agreement to FP precision (modulo time integration order — TL with +direct $\bar F$ specification vs UL with $\bar F^{(n)}$ derived from +$\bar L^{(n)}$ time integration may have $O(\Delta t)$ differences which +are expected). + +This test, if it can be constructed, is the strongest validation that the +UL adaptation is correct: the same physics solved by two different +formulations should agree. + +#### Test 4: Hill-Mandel theorem at every step + +For any converged state, the Hill-Mandel condition states + +$$ +\bar P : \dot{\bar F} = \frac{1}{|\Omega_0|} \int_{\Omega_0} P : \dot F\, d\Omega_0, +\tag{P5.8.11.a} +$$ + +i.e., the macroscopic stress power equals the volume-average of the +microscopic stress power. This must hold to FP precision at any converged +state, regardless of step count or deformation magnitude. + +**Implementation**: register Hill-Mandel power balance as a per-step +diagnostic, computed by `PostProcessingDriver` from the macroscopic +$(\bar P, \dot{\bar F})$ and the volume-averaged microscopic +$(\langle P \rangle, \langle \dot F \rangle)$. Print residual; CI flag if +> 1e-10 relative. + +### §P5.8.12 References + +- Belytschko, T., Liu, W.K., & Moran, B. *Nonlinear Finite Elements for + Continua and Structures*. Wiley, 2014. +- Benzi, M., Golub, G.H., & Liesen, J. "Numerical solution of saddle point + problems." *Acta Numerica* 14 (2005): 1–137. +- Bernardi, C., Maday, Y., & Patera, A.T. "A new nonconforming approach to + domain decomposition: the mortar element method." *IMA J. Numer. Anal.* + 14 (1994): 1–13. +- Bonet, J., & Wood, R.D. *Nonlinear Continuum Mechanics for Finite Element + Analysis*. Cambridge UP, 2008. +- Hill, R. "Elastic properties of reinforced solids: some theoretical + principles." *J. Mech. Phys. Solids* 11 (1963): 357–372. (Hill-Mandel + theorem.) +- Lopes, I.A.R., Ferreira, B.P., & Pires, F.M.A. "On the efficient + enforcement of uniform traction and mortar periodic boundary conditions + in computational homogenisation." *CMAME* 384 (2021): 113930. +- Mandel, J. "Contribution théorique à l'étude de l'écrouissage et des lois + de l'écoulement plastique." *Proc. 11th Int. Congress on Applied + Mechanics*, Munich (1965). +- Nervi, J.E. & Idiart, M.I. *International Journal of Plasticity* 65 + (2015): 30–52. +- Pazner, W. & Kolev, T. (LOR; see Phase 6 references.) +- Popp, A., Gee, M.W., & Wall, W.A. "A finite deformation mortar contact + formulation using a primal–dual active set strategy." *IJNME* 79 (2009): + 1354–1391. +- Popp, A., Wohlmuth, B.I., Gee, M.W., & Wall, W.A. "Dual quadratic mortar + finite element methods for 3D finite deformation contact." *SIAM + J. Sci. Comput.* 34 (2012): B421–B446. +- Puso, M.A. "A 3D mortar method for solid mechanics." *IJNME* 59 (2004): + 315–336. +- Puso, M.A. & Laursen, T.A. "A mortar segment-to-segment contact method for + large deformation solid mechanics." *CMAME* 193 (2004): 601–629. +- Reis, F.J.P. & Pires, F.M.A. "A mortar based approach for the enforcement + of periodic boundary conditions on arbitrarily generated meshes." *CMAME* + 274 (2014): 168–191. +- Wohlmuth, B.I. "A mortar finite element method using dual spaces for the + Lagrange multiplier." *SIAM J. Numer. Anal.* 38 (2000): 989–1012. +- Wohlmuth, B.I. *Discretization Techniques and Iterative Solvers Based on + Domain Decomposition*. Springer LNCSE 17, 2001. +- Wohlmuth, B.I. & Popp, A. Chapter in *Mortar Methods for Single- and + Multi-Field Applications*, Springer (2014). + +--- + +## §P5.9 Multi-region, assembly mode, and GPU compatibility + +These are properties of the Phase 4 stack, not of the integration: + +- **Multi-region**: the constraint depends on geometric topology only, not + material. ExaCMech / UMAT / MultiExaModel work without change. Phase 4 + strip-split and checkerboard tests prove this on the linear-elastic side; + behavior carries through to nonlinear K. +- **Assembly**: Day 1 default is EA for the constraint (matches PA K for + GPU). HypreParMatrix path retained for debug / direct-solver use. Both + validated bit-tight in Phase 4. +- **GPU**: Phase 4.3.B status applies — forward `Mult` is GPU-clean; + atomic-add `MultTranspose` is in flight. The integration inherits whatever + GPU support Phase 4.3.B delivers. + +--- + +## §P5.10 Output and post-processing + +`PostProcessingDriver` already computes $\langle F \rangle$ and +$\langle \sigma \rangle$ — Phase 5 does *not* duplicate these. The mortar +PBC adds three specific outputs: + +### §P5.10.1 Per-step diagnostic: $\langle L \rangle$ + +A new per-step quantity printed alongside the existing volume-averaged +stress / strain rate: the volume-averaged spatial velocity gradient +$\langle L \rangle = \langle \nabla v \rangle$. For a correctly converged +mortar PBC step, $\langle L \rangle$ should equal the prescribed +$L_\text{macro}$ to FP precision. Drift is the canonical diagnostic for a +misbehaving constraint. + +Implementation: extend `PostProcessingDriver` with a +`RegisterVelocityGradientAverage` call analogous to the existing stress / +strain rate registrations. Computed via the existing volume-averaging +machinery on the velocity gradient quadrature function. + +### §P5.10.2 ParaView field: $\tilde v = v - v_\text{lin}$ + +For visualization of the heterogeneous fluctuation. New `ParGridFunction` +field "v_tilde" registered with the existing `PostProcessingDriver`'s +ParaView pipeline. Computation is one +`MortarPbcManager::ComputeFluctuationField` call per output step, using +current mesh nodes to compute $v_\text{lin} = \bar L^{(n)} \cdot (x^{(n)} - x_0)$ +in spatial form. + +This is the analogue of $\tilde u$ in the test drivers, adapted for the +velocity primal. Useful for spotting localization, identifying whether the +fluctuation is small enough to justify a coarser RVE, and validating that +periodicity holds visually. + +### §P5.10.3 Hill-Mandel power balance + +Per §P5.8.11 Test 4: a per-step diagnostic computing the relative residual + +$$ +r^{(n)}_\text{HM} = \frac{|\bar P^{(n)} : \dot{\bar F}^{(n)} - \langle P^{(n)} : \dot F^{(n)} \rangle|}{|\bar P^{(n)} : \dot{\bar F}^{(n)}|}. +$$ + +Should be $< 10^{-10}$ at every converged step. CI failure flag if it +exceeds this threshold. + +Implementation: `MortarPbcManager::ComputeHillMandelPowerBalance()` (already +declared in the public API, §P5.4.1). Macroscopic $\bar P^{(n)}$ from the +assembled $\lambda$ via Lopes et al. eq. 37; macroscopic $\dot{\bar F}^{(n)}$ +already cached in `m_macro_Fdot`. Volume-average $\langle P : \dot F \rangle$ +from the existing `PostProcessingDriver` machinery. + +**Implementation note** (~30 LOC of new code in `MortarPbcManager`): Lopes +et al. eq. 37 expresses the macroscopic first PK stress as a rank-1 sum +over Lagrange-multiplier rows weighted by reference homologous-pair +offsets: + +$$ +\bar P^{(n)} = \frac{1}{|\Omega_0|} \sum_i \lambda_i^{(n)} \otimes (X_i^+ - X_i^-) +$$ + +where the sum is over all LM rows in the constraint, $\lambda_i^{(n)}$ +is the converged multiplier for row $i$ at step $n$, and $(X_i^+ - X_i^-)$ +is the reference homologous-pair offset for that row. Both are +already cached: $\lambda$ is `MortarPbcManager::m_lambda`, and the +offsets are inside `m_rhs_geometric_factors` (which stores +$\hat \ell^{\hat E_0}_i \cdot (X^+ - X^-)_{\text{pair}(i)}$ — divide +out the lumped row-sum to recover the offset). The implementation is +a single MPI-reduce loop: + +```cpp +double ComputeHillMandelPowerBalance() const { + // 1) Build local rank-1 sum into a 3×3 dense matrix. + mfem::DenseMatrix P_bar_local(3); P_bar_local = 0.0; + const auto LAMBDA = m_lambda.HostRead(); + const auto FACTORS = m_rhs_geometric_factors.HostRead(); + const int nrows = m_builder->NumLocalRows(); + for (int i = 0; i < nrows; ++i) { + // factors[i] = lumped_row_sum * (X+-X-); recover offset. + const double inv_lump = 1.0 / m_classifier->LumpedRowSum(i); + for (int k = 0; k < 3; ++k) { + for (int l = 0; l < 3; ++l) { + P_bar_local(k, l) += LAMBDA[i] * FACTORS[i*3 + l] * inv_lump; + } + } + } + // 2) MPI_Allreduce to global P_bar. + mfem::DenseMatrix P_bar(3); + MPI_Allreduce(P_bar_local.Data(), P_bar.Data(), 9, + MPI_DOUBLE, MPI_SUM, m_pmesh.GetComm()); + P_bar *= 1.0 / m_volume_omega_0; // cached at construction + // 3) Compute the residual. + const double bar_power = MatrixDoubleContraction(P_bar, m_macro_Fdot); + const double micro_power = m_post_proc->ComputeVolumeAvgPDotF(); + return std::abs(bar_power - micro_power) / + std::max(std::abs(bar_power), 1.0e-30); +} +``` + +The classifier's `LumpedRowSum(i)` accessor for individual LM rows is +existing functionality in `BoundaryClassifier3D`. The `m_volume_omega_0` +cached value comes from a single `pmesh.GetGlobalNE()`-weighted sum at +construction. + +### §P5.10.4 Why no new output category + +These pieces fit cleanly into the existing `[PostProcessing.Projections]` +and `[Visualizations]` infrastructure as additional projection names: + +```toml +[PostProcessing.Projections] +enabled_projections = ["stress", "von_mises", "velocity_gradient_avg", + "v_tilde", "hill_mandel"] +``` + +No new output table; the user just enables the projections by name. + +--- + +## §P5.11 CMake / build system + +`src/mortar_pbc/` becomes a new subdirectory linked into `exaconstit_mech`. +Axom is conditionally enabled for non-conforming support. Minimal changes +to the existing build-system patterns: + +```cmake +# src/CMakeLists.txt — add the subdirectory. +add_subdirectory(mortar_pbc) + +# src/mortar_pbc/CMakeLists.txt +set(MORTAR_PBC_SOURCES + boundary_classifier_3d.cpp + constraint_builder_3d.cpp + face_mortar_assembler_3d.cpp + face_mortar_assembler_clipped_3d.cpp + mortar_assembler_2d.cpp + mortar_constraint_operator.cpp + saddle_point_solver.cpp + tile_partition_3d.cpp + boundary_helpers_3d.cpp + mortar_pbc_manager.cpp # new (Phase 5) +) + +add_library(exaconstit_mortar_pbc STATIC ${MORTAR_PBC_SOURCES}) +target_link_libraries(exaconstit_mortar_pbc + PUBLIC mfem MPI::MPI_CXX + PRIVATE caliper) + +if (MORTAR_PBC_HAS_AXOM) + target_compile_definitions(exaconstit_mortar_pbc PUBLIC MORTAR_PBC_HAS_AXOM) + target_link_libraries(exaconstit_mortar_pbc PUBLIC axom::core axom::primal axom::spin) +endif() + +target_include_directories(exaconstit_mortar_pbc PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}) +``` + +The `exaconstit_mech` library links against `exaconstit_mortar_pbc`. The +`system_driver` and `option_parser_v2` translation units include +`mortar_pbc/mortar_pbc_manager.hpp` as needed. + +When `ENABLE_AXOM=OFF`, the non-conforming code path is disabled at compile +time. Phase 4 sandbox already validates that the mortar code compiles +clean both with and without Axom. + +--- + +## §P5.12 Testing strategy + +Three layers, integrating §P5.8.11: + +### §P5.12.1 Unit tests (new) + +In `test/mortar_pbc/`: + +- `test_mortar_pbc_manager_construct` — basic construction / destruction + cycle, memory-leak-clean under valgrind on a small mesh. +- `test_mortar_pbc_manager_corner_tdofs` — verify the 24 corner TDOFs are + correctly identified and consistent across np=1, 4, 7. +- `test_mortar_pbc_manager_macro_F_update` — verify + `UpdateMacroscopicF` produces correct $\bar F^{(n)}$ values for a + multi-step monotonic loading sequence (compare against analytic matrix + exponential or trapezoidal rule integration). +- `test_mortar_pbc_manager_constraint_rhs` — verify `UpdateConstraintRHS` + produces a constant-per-pair RHS matching (P5.8.6.d) on a deformed mesh. + +### §P5.12.2 End-to-end small-problem tests (new) + +In `test/data/` with corresponding `.toml` files: + +- `mortar_pbc_linear_elastic.toml` — homogeneous linear-elastic 4×4×4 hex + RVE under simple shear, single time step. Reference: $\langle F \rangle = F_\text{macro}$ + and Hill-Mandel residual below 1e-12. +- `mortar_pbc_neohookean_50pct.toml` — single-grain Neohookean RVE, 100 + time steps simple shear ramping to ~50% strain (Test 1 of §P5.8.11). + Hill-Mandel residual below 1e-10 at every step; $\tilde v$ stays small; + Newton converges quadratically. +- `mortar_pbc_voce_polycrystal.toml` — small (~50 grain) polycrystal with + ExaCMech Voce hardening, 50 time steps under tension. Reference: + homogenized stress matches a known-good baseline; Hill-Mandel below 1e-10. +- `mortar_pbc_heterogeneous_strip_multistep.toml` — Phase 4 strip-split + promoted to 50 time steps (Test 2 of §P5.8.11). +- `mortar_pbc_checkerboard_multistep.toml` — Phase 4 checkerboard promoted + to 50 time steps. +- `mortar_pbc_restart.toml` — checkpoint at step 25, restart at step 26, + verify $\bar F^{(n)}$ and $\lambda$ are restored cleanly. + +Run all six at np=1, 4, 7. Add to CI. + +### §P5.12.3 Regression / performance benchmarks + +- A 32³ hex polycrystal with 200 grains, ExaCMech FCC, 50 time steps under + tension — compare wall-time and memory between mortar PBC enabled and + disabled (with appropriately matched conventional Dirichlet for the + disabled case). Goal: mortar PBC overhead < 30% on CPU, < 50% on GPU. +- A scaling study at np=1, 4, 16, 64, 256 (up to whatever Phase 4.2 + supports) — the saddle-point solver should be the dominant cost; classifier + setup should be < 10% of total time; constraint matrix construction + should happen once at startup and be < 5% of total time. + +### §P5.12.4 Test the "feature off" path + +Critical regression check: every existing `test/data/*.toml` test that +doesn't enable mortar PBC must produce bit-identical results before and +after Phase 5. This validates the inert-when-disabled invariant. Run as +part of CI on every PR. + +--- + +## §P5.13 Phasing + +Each batch lands focused, locally-testable work; the test suite stays green +at every step (including the "feature off" regression check). + +``` +Phase 5.0 — MortarSaddlePointSystem constraint-RHS extension +├── 5.0.A Add `SetConstraintRHS(const Vector& g)` and +│ `ClearConstraintRHS()` methods to +│ `test/mortar_pbc/mortar_saddle_point_system.{hpp,cpp}`. +│ Add private `m_g_rhs` member (mfem::Vector); store a +│ shallow copy via `MakeRef` to avoid extra allocation. +│ Modify `Mult(x_block, r_block)` so that, when an RHS is +│ installed, the constraint-side residual becomes +│ r_C_block = C * u - g +│ instead of the homogeneous form. Default state (no RHS +│ set, or after `ClearConstraintRHS()`) keeps the existing +│ homogeneous behavior. +├── 5.0.B Add a unit test in +│ `test/mortar_pbc/test_mortar_saddle_point_system.cpp` +│ that exercises both code paths: +│ (i) no RHS set → r_C = C·u (existing behavior). +│ (ii) RHS set → r_C = C·u - g (new behavior). +│ Verify on a small synthetic constraint that the residual +│ vanishes when u is constructed to satisfy C·u = g exactly. +├── 5.0.C Verify all existing Phase 4.3 tests still pass — the +│ default behavior is unchanged, so existing tests should +│ be untouched. Cross-check with `--constraint-storage=ea` +│ patch test on np = {1, 4, 7}. + + ↓ (gate: existing tests green; new RHS-mode test passes; + code-review sign-off on the small Phase 4.3 modification) + +Phase 5.1 — Promote test/mortar_pbc/ → src/mortar_pbc/ +├── 5.1.A Move files; update CMake; verify existing tests still pass. +├── 5.1.B Linking from main exaconstit_mech library; verify the main +│ binary builds with mortar PBC code present but unused. + + ↓ (gate: existing test/mortar_pbc/ tests still green) + +Phase 5.2 — Options: snap_tol, lor_depth, SaddlePointSolverOptions +├── 5.2.A Add to MeshOptions / SolverOptions; parse [Mesh] + +│ [Solvers.SaddlePoint] tables; validate. +├── 5.2.B Verify TOML without mortar enabled parses identically before/after. + + ↓ (gate: option parsing test green; existing TOMLs unchanged) + +Phase 5.3 — MortarPbcManager class (depends on Phase 5.0) +├── 5.3.A Class skeleton: constructor takes K residual / K Jacobian +│ closures (per §P5.4.1) and delegates to existing Phase 4 +│ classifier/builder/operator/solver setup; F̄ initialized to I. +│ Constructor builds the MortarSaddlePointSystem internally +│ from the closures and the EA constraint operator. +├── 5.3.B Corner-TDOF identification: walk the BoundaryClassifier3D +│ CornerInfo3D records, build the 24-element Array. +├── 5.3.C UpdateMacroscopicF + UpdateConstraintRHS implementation. +│ UpdateConstraintRHS calls `m_saddle_system->SetConstraintRHS` +│ (the Phase 5.0 method). Unit test against analytic values +│ for a multi-step monotonic loading. +├── 5.3.D ComputeFluctuationField + ComputeHillMandelPowerBalance. +│ Unit test on a homogeneous problem (Hill-Mandel ≡ 0). +├── 5.3.E λ accumulation API: GetLambda, ResetLambda, warm-start +│ between steps. Unit test: λ persists across multiple +│ saddle-point step calls. + + ↓ (gate: MortarPbcManager standalone tests green) + +Phase 5.4 — NonlinearMechOperator::UpdateEssTDofsCornerSubset +├── 5.4.A Add UpdateEssTDofsCornerSubset(const Array&) method that +│ bypasses attribute expansion. +├── 5.4.B Verify ParNonlinearForm::SetEssentialTrueDofs handles a +│ 24-TDOF list correctly. Smoke test. + + ↓ (gate: mech_operator with corner-only ess TDOFs gives + expected F and K) + +Phase 5.5 — SystemDriver wiring +├── 5.5.A Constructor: detect mortar PBC enabled, build manager, override +│ ess TDOFs, route saddle-point Operator to Newton solver. +├── 5.5.B Solve() / SolveInit() / UpdateVelocity() / UpdateEssBdr() +│ mortar branches. +├── 5.5.C Per-step UpdateMacroscopicF + UpdateConstraintRHS hook in +│ mechanics_driver main loop. + + ↓ (gate: end-to-end mortar PBC simulations run through + SystemDriver correctly on linear-elastic test) + +Phase 5.6 — Newton solver compatibility +├── 5.6.A ExaNewtonSolver: linear-elastic patch test through MortarSaddle- +│ PointSystem. Convergence in ~1 Newton iter for linear case. +├── 5.6.B ExaNewtonLSSolver: Neohookean RVE patch test under moderate +│ strain (10%); converges in < 20 Newton iters. +├── 5.6.C ExaTrustRegionSolver: Neohookean RVE under severe strain (50% +│ shear) to validate TRDOG retains its advantage. + + ↓ (gate: all three Newton variants converge with mortar PBC) + +Phase 5.7 — Validation suite (the six small-problem tests) +├── 5.7.A mortar_pbc_linear_elastic.toml +│ Also: promote the `MortarPbcManager::DiagnoseConstraintConsistency` +│ consistency check (||C·v_aff - g||_inf < tol for v_aff = F̄·x) +│ to a ctest assertion at n ∈ {2³, 3³, 4³}. This is the precise +│ test that catches the perm-reorder trap of §P5.14.10 and any +│ structurally similar future regressions in C assembly. +├── 5.7.B mortar_pbc_neohookean_50pct.toml (Test 1 of §P5.8.11) +├── 5.7.C mortar_pbc_voce_polycrystal.toml +├── 5.7.D mortar_pbc_heterogeneous_strip_multistep.toml (Test 2) +├── 5.7.E mortar_pbc_checkerboard_multistep.toml (Test 2) +├── 5.7.F mortar_pbc_restart.toml + Each at np=1, 4, 7. Add all to CI. + + ↓ (gate: all six validation tests green at np=1, 4, 7) + +Phase 5.8 — Output: ⟨L⟩, v_tilde, Hill-Mandel diagnostic +├── 5.8.A Register ⟨L⟩ projection in PostProcessingDriver. +├── 5.8.B Register v_tilde ParGridFunction; ParaView pipeline updated. +├── 5.8.C Register Hill-Mandel diagnostic per §P5.10.3. + + ↓ (gate: outputs reach files cleanly; Hill-Mandel < 1e-10 + on all validation tests) + +Phase 5.9 — Component-restricted PBC (spec-driven constraint filter) +├── 5.9.A Option layer: PeriodicBC struct (essential_ids + +│ essential_comps); BoundaryOptions::periodic_bcs + +│ periodic_bc_entry_per_step (sparse step→entry map). +│ PeriodicBC::validate() catches non-positive attrs and +│ out-of-range comps; pair-completeness deferred to +│ manager (requires classifier). +├── 5.9.B Classifier extensions: LabelForMeshAttribute / +│ MeshAttributeForLabel / PairPartnerLabel / ArePaired / +│ CornersOnFaceAttribute / IsBoundaryFaceAttribute / +│ AnchorCornerTDofs. All cache-only (O(1) lookups against +│ pre-built tables). +├── 5.9.C ConstraintBuilder3D filtered overloads of Build, +│ BuildHypreParMatrix, EmitRowFactors, NumLocalRows, +│ NumConstraints accepting (active_pair_labels, comp_mask). +│ Parameter-less variants forward with {all pairs, all +│ comps} for back-compat. Filter rules: face pair active +│ iff axis in active_axes; edge group active iff BOTH +│ perpendicular axes in active_axes. +├── 5.9.D MortarConstraintOperator::Reset(active_pair_labels, +│ comp_mask). LOCAL, no MPI. Re-walks flat row arrays; +│ updates m_n_comps_active, m_local_c[3], m_row_lambda_off, +│ Height. Does NOT rebuild edge_pair blocks (all 9 kept) +│ or off-rank import/export topology (over-imports under +│ reduced filter — correct, bounded waste). Native +│ mfem::forall kernels for Mult / MultTranspose / +│ ComputeInvDiagSchur honor m_local_c. +├── 5.9.E Manager integration: +│ - RebuildForActiveSpec(essential_ids, essential_comps): +│ validates pairs, calls Reset, refreshes saddle system, +│ recomputes corner ess TDOFs, resizes m_lambda / m_g_rhs, +│ re-emits per-row factors. +│ - SynthesizeDefaultPbcSpec(classifier) static helper. +│ - ComputeCornerEssTDofsFromSpec free function: anchor +│ unconditional + incident-face gate + comp_mask. +│ - MortarSaddlePointSystem::Refresh method + invocation +│ from RebuildForActiveSpec (stale-cache hotfix). +├── 5.9.F SystemDriver hook: +│ - SyncMortarPbcForStep(step_idx) with full state machine +│ (default-synth, idempotence, sparse update_steps, +│ config-error abort). +│ - Two private members: m_pbc_initialized, +│ m_pbc_active_entry_idx (with -1 sentinel for default). +│ - Ctor wiring: SyncMortarPbcForStep(1) replaces inline +│ UpdateEssTDofsCornerSubset call. +│ - m_x_saddle reallocation + newton_solver re-SetOperator +│ on transitions. +├── 5.9.G mechanics_driver.cpp: add SyncMortarPbcForStep(ti) call +│ inside the BCManager::GetUpdateStep transition block, +│ immediately before UpdateEssBdr. +├── 5.9.H Tests: +│ - test_constraint_builder_3d.cpp: three new filter cases +│ (X-only, X-face-pair-only, empty) plus the +│ period_signed_per_row signature fix from §P5.7.A. +│ - test_mortar_pbc_manager_filter.cpp (new file): five +│ cases of ComputeCornerEssTDofsFromSpec (full-XYZ, +│ X-only-single-pair, XY-two-pairs, anchor-only, +│ round-trip XYZ→X→XYZ). +├── 5.9.I Integration validation: linear-elastic uniaxial-X patch +│ test runs end-to-end with correct Newton convergence +│ and Width() = x_block.Size() = 429 on a 4×4×4 mesh. +│ Multi-entry production test (full-XYZ step 1 → X-only +│ step 5) deferred to follow-on validation work. + + ↓ (gate: linear-elastic patch test green; existing fully- + periodic regression suite still green; test_mortar_ + pbc_manager_filter and test_constraint_builder_3d all + cases pass) + +Phase 5.10 — Performance benchmarks + GPU validation + documentation +├── 5.10.A CPU performance benchmark (32³ polycrystal, ExaCMech FCC). +├── 5.10.B GPU validation on MI300A and/or A100. +├── 5.10.C Multi-rank scaling study. +├── 5.10.D Documentation: developers_guide.md update; README.md update; +│ tutorial example. + + ↓ (gate: performance acceptable; GPU runs validate; docs reviewed) + +Phase 5.11 — Saddle-system residual scaling +├── 5.11.A Options layer: `SaddleScalingOptions` table under +│ `[Solvers.SaddlePoint.Scaler]` (enabled, per_subblock, +│ partition, floor, range_cap). Parsed in option_parser_v2; +│ decoded into a `mortar_pbc::SaddleResidualScalerConfig` +│ at the `MortarPbcManager` boundary, mirroring the +│ `SaddlePointSolverOptions` → `SaddlePointSolverConfig` +│ pattern (separation of options-side and mortar-pbc-side +│ headers). +├── 5.11.B Partition machinery: `mortar_pbc::SubblockPartition` enum +│ (`FaceEdge`, `PerPair`) added in `constraint_builder_3d.hpp`, +│ kept distinct from the options-side `::SubblockPartition` +│ to avoid pulling option_parser_v2.hpp into mortar_pbc +│ headers. `ConstraintBuilder3D::GetRowSubblockIds` populates +│ the per-row sub-block index array + the per-sub-block +│ label vector used downstream. +├── 5.11.C `SaddleResidualScaler` class: holds the current scaling +│ state (`m_d_u`, per-row `m_d_lambda`, per-sub-block +│ `m_subblock_factor`); `Choose(r_u_norm, subblock_norms)` +│ applies Rule A unit-balance with floor / range_cap guards; +│ in-place `ApplyToResidual`, `UnapplyToIncrement`, +│ `ApplyToIncrement` for use by the wrappers and the +│ Newton-side convergence test. All operations local — no +│ MPI; caller is responsible for the residual-norm +│ reductions. +├── 5.11.D Operator / solver / preconditioner wrappers (`ScaledSaddle- +│ Operator`, `ScaledSaddleSolver`, `ScaledSaddlePreconditioner`). +│ Always constructable around the underlying Phase 4.3 +│ saddle stack; act as math no-ops when the scaler's +│ `IsEnabled()` is false. The operator wrapper sees +│ Newton's `oper->Mult` and `oper->GetGradient` paths and +│ applies `D^-1` on residuals + wraps the Jacobian as +│ `ScaledJacobianOperator` (which evaluates `D^-1 J D` on +│ the fly with no matrix-storage cost). +├── 5.11.E Manager-side wiring: `MortarPbcManager` owns the scaler; +│ `ChooseScalingForStep` runs at the top of each step, +│ gathering the initial residual block norms (one +│ Allreduce) and calling `Choose`. Re-built on Phase-5.9 +│ spec transitions so the partition tracks the active +│ filter. +├── 5.11.F Newton-side diagnostic callback API: `NewtonDiagnosticSink` +│ = `function` on +│ `ExaNewtonSolver` and `ExaNewtonLSSolver`. Invoked at the +│ top of each Newton iter AFTER the new residual norm is +│ computed and BEFORE the convergence-check break. The +│ diagnostic struct carries iter / norm / norm0 / norm_max +│ / converged_now plus non-owning pointers to the current +│ residual and solution iterate. +├── 5.11.G TRDOG integration: `ExaTrustRegionSolver::SetScaler` lets +│ the dogleg body convert between solver-coord (`c` from +│ `prec_mech->Mult`) and physical-coord directions when +│ interpolating against gradients evaluated by +│ `ScaledJacobianOperator::MultTranspose`. Math: with +│ asymmetric `D^-1 J D`, the natural-output coord of the +│ inner solver is solver-coords; gradient is naturally +│ scaled-coords; reconciling those for the dogleg +│ interpolation needs one `ApplyToIncrement` per step. +├── 5.11.H SystemDriver wraps Newton+J_solver+J_prec with the +│ scaling stack when the scaler is enabled. The unwrapped +│ saddle-stack path is preserved bit-for-bit when the +│ scaler is disabled (identity short-circuit in the +│ wrappers + gated install in SystemDriver). +├── 5.11.I Per-iter diagnostic CSV (rolled into 5.11.H's delivery +│ bundle): rank-0-only file write, columns for +│ `step / iter / norm / norm0 / norm_max / converged_now / +│ scaler_enabled`. +├── 5.11.J Rich diagnostic logger +│ (`SaddleNewtonDiagnosticLogger`): per-block residual +│ decomposition into `res_K`, `res_lam`, and per-sub-block +│ `res_lam_